Skip to main content

hjkl_clipboard/
lib.rs

1//! Cross-platform clipboard library with rich types, async support, and
2//! context-aware backend selection (native desktop, OSC 52 over SSH / tmux).
3//!
4//! # Quick start
5//!
6//! ```rust,no_run
7//! use hjkl_clipboard::{Clipboard, Selection, MimeType};
8//!
9//! let cb = Clipboard::new().unwrap();
10//! cb.set(Selection::Clipboard, MimeType::Text, b"hello").unwrap();
11//! let data = cb.get(Selection::Clipboard, MimeType::Text).unwrap();
12//! assert_eq!(data, b"hello");
13//! ```
14//!
15//! # Custom backends
16//!
17//! [`Clipboard::with_backend`] accepts any `Box<dyn Backend>`, enabling test
18//! mocks ([`backend::mock::MockBackend`]), decorators
19//! ([`backend::ssh_aware::SshAwareBackend`]), or third-party impls. Check
20//! [`Clipboard::capabilities`] before invoking methods that may return
21//! [`ClipboardError::UnsupportedMime`] or [`ClipboardError::UnsupportedAsync`].
22
23pub mod capabilities;
24pub mod error;
25pub mod mime;
26pub mod selection;
27pub mod uri;
28
29pub mod backend;
30pub(crate) mod base64;
31pub(crate) mod cf_hdrop;
32pub(crate) mod cf_html;
33pub(crate) mod dib_png;
34pub(crate) mod oneshot;
35pub(crate) mod osc52;
36pub(crate) mod reply;
37
38pub use backend::Backend;
39pub use capabilities::{BackendKind, Capabilities};
40pub use error::ClipboardError;
41pub use mime::MimeType;
42pub use selection::Selection;
43pub use uri::Uri;
44
45/// Returns `true` when the process is running inside an SSH session.
46///
47/// Checks the standard variables `sshd` exports into the session environment:
48/// `SSH_TTY` (interactive session with a controlling terminal), `SSH_CONNECTION`,
49/// and `SSH_CLIENT`. Any one being present is treated as "remote".
50pub(crate) fn is_ssh_session() -> bool {
51    std::env::var_os("SSH_TTY").is_some()
52        || std::env::var_os("SSH_CONNECTION").is_some()
53        || std::env::var_os("SSH_CLIENT").is_some()
54}
55
56/// A handle to the system clipboard.
57///
58/// Internally holds a `Box<dyn Backend>` chosen by [`Clipboard::new`] (probes
59/// the best available platform backend) or supplied by the caller via
60/// [`Clipboard::with_backend`].
61///
62/// Use [`Clipboard::kind`] / [`Clipboard::capabilities`] to introspect the
63/// active backend before invoking methods that may return
64/// [`ClipboardError::UnsupportedMime`] / [`ClipboardError::UnsupportedAsync`].
65pub struct Clipboard {
66    backend: Box<dyn Backend>,
67}
68
69impl Clipboard {
70    /// Construct a new clipboard handle, choosing the backend that fits the
71    /// current context.
72    ///
73    /// Selection order:
74    /// 1. An explicit `HJKL_CLIPBOARD` override (see [`Self::forced_backend`]).
75    /// 2. **SSH session** (`SSH_TTY` / `SSH_CONNECTION` / `SSH_CLIENT` set) →
76    ///    OSC 52. Over SSH the machine's *native* clipboard belongs to the
77    ///    remote host, not the user; the OSC 52 terminal escape is relayed by
78    ///    the user's terminal emulator to their **local** clipboard (and is
79    ///    wrapped for tmux passthrough automatically when `TMUX` is set).
80    /// 3. Otherwise the local-desktop probe:
81    ///    - Linux: Wayland → X11 → OSC 52.
82    ///    - macOS: NSPasteboard (always available).
83    ///    - Windows: Win32 (always available).
84    ///    - Other: OSC 52.
85    ///
86    /// The SSH heuristic is overridable: set `HJKL_CLIPBOARD=x11` (or
87    /// `wayland`) when relying on SSH X11 forwarding to reach the local
88    /// clipboard natively, or `HJKL_CLIPBOARD=osc52` to force OSC 52 anywhere.
89    pub fn new() -> Result<Self, ClipboardError> {
90        if let Some(forced) = Self::forced_backend() {
91            return Ok(forced);
92        }
93        // Context-aware default: an SSH session's native clipboard is the
94        // remote host's, so route through OSC 52 to the user's local terminal.
95        if is_ssh_session() {
96            return Ok(Self::with_backend(Box::new(
97                backend::osc52::Osc52Backend::new(),
98            )));
99        }
100        Self::probe()
101    }
102
103    /// Honor an explicit `HJKL_CLIPBOARD` backend selection. Returns `None`
104    /// when the variable is unset or holds a value that isn't usable on this
105    /// platform (fall through to the context-aware default).
106    ///
107    /// Recognized values (case-insensitive): `osc52`, `native`, and the
108    /// platform backend names `wayland` / `x11` (Linux), `macos`, `windows`.
109    /// `native` forces the platform probe, bypassing the SSH heuristic. A
110    /// named native backend that fails to initialize (e.g. `x11` with no
111    /// display) falls through rather than erroring.
112    fn forced_backend() -> Option<Self> {
113        let raw = std::env::var("HJKL_CLIPBOARD").ok()?;
114        match raw.trim().to_ascii_lowercase().as_str() {
115            "osc52" => Some(Self::with_backend(Box::new(
116                backend::osc52::Osc52Backend::new(),
117            ))),
118            // Bypass the SSH heuristic and use the local-desktop probe.
119            "native" | "auto" => Self::probe().ok(),
120            #[cfg(target_os = "linux")]
121            "wayland" => backend::wayland_backend::WaylandBackend::new()
122                .ok()
123                .map(|b| Self::with_backend(Box::new(b))),
124            #[cfg(target_os = "linux")]
125            "x11" => backend::x11_backend::X11Backend::new()
126                .ok()
127                .map(|b| Self::with_backend(Box::new(b))),
128            #[cfg(target_os = "macos")]
129            "macos" => Some(Self::with_backend(Box::new(
130                backend::macos::MacosBackend::new(),
131            ))),
132            #[cfg(target_os = "windows")]
133            "windows" => Some(Self::with_backend(Box::new(
134                backend::windows::WindowsBackend::new(),
135            ))),
136            _ => None,
137        }
138    }
139
140    /// Construct a clipboard handle from a caller-supplied backend.
141    ///
142    /// Use this for tests ([`backend::mock::MockBackend`]), decorators
143    /// ([`backend::ssh_aware::SshAwareBackend`]), or any custom `Backend` impl.
144    pub fn with_backend(backend: Box<dyn Backend>) -> Self {
145        Self { backend }
146    }
147
148    #[cfg(target_os = "linux")]
149    fn probe() -> Result<Self, ClipboardError> {
150        // Prefer Wayland.
151        match backend::wayland_backend::WaylandBackend::new() {
152            Ok(b) => return Ok(Self::with_backend(Box::new(b))),
153            Err(ClipboardError::LibNotFound)
154            | Err(ClipboardError::NoDisplay)
155            | Err(ClipboardError::FocusRequired) => {}
156            Err(e) => return Err(e),
157        }
158        // Try X11.
159        match backend::x11_backend::X11Backend::new() {
160            Ok(b) => return Ok(Self::with_backend(Box::new(b))),
161            Err(ClipboardError::LibNotFound) | Err(ClipboardError::NoDisplay) => {}
162            Err(e) => return Err(e),
163        }
164        // OSC 52 fallback.
165        Ok(Self::with_backend(Box::new(
166            backend::osc52::Osc52Backend::new(),
167        )))
168    }
169
170    #[cfg(target_os = "macos")]
171    fn probe() -> Result<Self, ClipboardError> {
172        Ok(Self::with_backend(Box::new(
173            backend::macos::MacosBackend::new(),
174        )))
175    }
176
177    #[cfg(target_os = "windows")]
178    fn probe() -> Result<Self, ClipboardError> {
179        Ok(Self::with_backend(Box::new(
180            backend::windows::WindowsBackend::new(),
181        )))
182    }
183
184    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
185    fn probe() -> Result<Self, ClipboardError> {
186        Ok(Self::with_backend(Box::new(
187            backend::osc52::Osc52Backend::new(),
188        )))
189    }
190
191    // -------------------------------------------------------------------------
192    // Introspection
193    // -------------------------------------------------------------------------
194
195    /// Stable identifier for the active backend.
196    pub fn kind(&self) -> BackendKind {
197        self.backend.kind()
198    }
199
200    /// Capability bitmask for the active backend. Cheap — call before any
201    /// op that could return `UnsupportedMime` / `UnsupportedAsync`.
202    pub fn capabilities(&self) -> Capabilities {
203        self.backend.capabilities()
204    }
205
206    /// Stable lowercase string identifier (kept for diagnostic output).
207    /// Equivalent to `self.kind().as_str()`.
208    pub fn backend_name(&self) -> &'static str {
209        self.backend.kind().as_str()
210    }
211
212    // -------------------------------------------------------------------------
213    // Sync API
214    // -------------------------------------------------------------------------
215
216    /// Write `bytes` to `sel` as `mime`.
217    pub fn set(&self, sel: Selection, mime: MimeType, bytes: &[u8]) -> Result<(), ClipboardError> {
218        self.backend.set(sel, mime, bytes)
219    }
220
221    /// Read the current contents of `sel` as `mime`.
222    pub fn get(&self, sel: Selection, mime: MimeType) -> Result<Vec<u8>, ClipboardError> {
223        self.backend.get(sel, mime)
224    }
225
226    /// Clear `sel`.
227    pub fn clear(&self, sel: Selection) -> Result<(), ClipboardError> {
228        self.backend.clear(sel)
229    }
230
231    /// Return the MIME types currently available in `sel`.
232    pub fn available(&self, sel: Selection) -> Result<Vec<MimeType>, ClipboardError> {
233        self.backend.available(sel)
234    }
235
236    // -------------------------------------------------------------------------
237    // Async API — backends opt in via Capabilities::ASYNC_*; default returns
238    // ClipboardError::UnsupportedAsync.
239    // -------------------------------------------------------------------------
240
241    /// Async version of [`set`][Self::set]. Bytes are cloned so the future is
242    /// `'static`.
243    pub async fn set_async(
244        &self,
245        sel: Selection,
246        mime: MimeType,
247        bytes: &[u8],
248    ) -> Result<(), ClipboardError> {
249        self.backend.set_async(sel, mime, bytes.to_vec()).await
250    }
251
252    /// Async version of [`get`][Self::get].
253    pub async fn get_async(
254        &self,
255        sel: Selection,
256        mime: MimeType,
257    ) -> Result<Vec<u8>, ClipboardError> {
258        self.backend.get_async(sel, mime).await
259    }
260
261    /// Async version of [`clear`][Self::clear].
262    pub async fn clear_async(&self, sel: Selection) -> Result<(), ClipboardError> {
263        self.backend.clear_async(sel).await
264    }
265
266    /// Async version of [`available`][Self::available].
267    pub async fn available_async(&self, sel: Selection) -> Result<Vec<MimeType>, ClipboardError> {
268        self.backend.available_async(sel).await
269    }
270
271    // -------------------------------------------------------------------------
272    // Typed uri-list helpers
273    // -------------------------------------------------------------------------
274
275    /// Write a list of URIs to `sel`.
276    ///
277    /// Relative paths in `File` variants return [`ClipboardError::InvalidUri`].
278    /// Encoding validation happens before the backend is called, so encoding
279    /// errors are visible even in tests that don't wire up a real backend.
280    pub fn set_uri_list(&self, sel: Selection, uris: &[Uri]) -> Result<(), ClipboardError> {
281        let bytes = crate::uri::encode_uri_list(uris)?;
282        self.set(sel, MimeType::UriList, &bytes)
283    }
284
285    /// Read a uri-list from `sel` and parse it into typed [`Uri`] values.
286    pub fn get_uri_list(&self, sel: Selection) -> Result<Vec<Uri>, ClipboardError> {
287        let bytes = self.get(sel, MimeType::UriList)?;
288        crate::uri::decode_uri_list(&bytes)
289    }
290
291    /// Return true if the active backend is OSC 52.
292    ///
293    /// Used in tests to verify fallback / forced backend selection without
294    /// needing a display. Available on every platform so the
295    /// `HJKL_CLIPBOARD=osc52` override can be verified on macOS/Windows too.
296    #[cfg(test)]
297    pub(crate) fn is_osc52(&self) -> bool {
298        self.backend.kind() == BackendKind::Osc52
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    #[allow(unused_imports)]
305    use super::*;
306
307    #[test]
308    fn backend_name_returns_valid_string() {
309        let valid = ["wayland", "x11", "macos", "windows", "osc52"];
310        if let Ok(cb) = Clipboard::new() {
311            assert!(
312                valid.contains(&cb.backend_name()),
313                "unexpected backend_name: {}",
314                cb.backend_name()
315            );
316        }
317    }
318
319    /// Verify the exact OSC 52 escape sequence for a known payload.
320    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
321    #[test]
322    fn osc52_backend_set_and_get() {
323        use backend::osc52::Osc52Backend;
324        use osc52::is_in_tmux;
325
326        let b = Osc52Backend::new();
327        let mut buf = Vec::new();
328
329        b.set_inner(Selection::Clipboard, MimeType::Text, b"hello", &mut buf)
330            .expect("set_inner failed");
331
332        let seq = std::str::from_utf8(&buf).expect("output not UTF-8");
333
334        let body = if is_in_tmux() {
335            assert!(
336                seq.starts_with("\x1bPtmux;\x1b\x1b]52;c;"),
337                "wrong DCS prefix: {seq:?}"
338            );
339            assert!(seq.ends_with("\x07\x1b\\"), "wrong DCS suffix: {seq:?}");
340            seq.strip_prefix("\x1bPtmux;\x1b\x1b]52;c;")
341                .unwrap()
342                .strip_suffix("\x07\x1b\\")
343                .unwrap()
344        } else {
345            assert!(seq.starts_with("\x1b]52;c;"), "wrong OSC prefix: {seq:?}");
346            assert!(seq.ends_with('\x07'), "wrong BEL suffix: {seq:?}");
347            seq.strip_prefix("\x1b]52;c;")
348                .unwrap()
349                .strip_suffix('\x07')
350                .unwrap()
351        };
352
353        assert_eq!(body, "aGVsbG8=", "base64 mismatch for 'hello'");
354
355        // get is always UnsupportedMime for OSC 52.
356        let cb = Clipboard::with_backend(Box::new(Osc52Backend::new()));
357        assert!(cb.is_osc52(), "expected Osc52 backend");
358        let err = cb.get(Selection::Clipboard, MimeType::Text).unwrap_err();
359        assert!(
360            matches!(err, ClipboardError::UnsupportedMime),
361            "expected UnsupportedMime from osc52 get, got: {err}"
362        );
363
364        // available is always empty.
365        let mimes = cb.available(Selection::Clipboard).unwrap();
366        assert!(mimes.is_empty(), "expected empty available from osc52");
367    }
368
369    /// Serialize env mutation across all env-touching tests so a parallel
370    /// `cargo test` run can't observe a torn value (nextest already isolates
371    /// per-process).
372    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
373
374    /// Run `f` with `vars` applied (`Some` = set, `None` = remove), restoring
375    /// each variable's prior value afterward. Serialized via [`ENV_LOCK`].
376    fn with_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
377        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
378        let saved: Vec<(String, Option<String>)> = vars
379            .iter()
380            .map(|(k, _)| ((*k).to_string(), std::env::var(k).ok()))
381            .collect();
382        for (k, v) in vars {
383            // SAFETY: guarded by ENV_LOCK; restored below.
384            unsafe {
385                match v {
386                    Some(val) => std::env::set_var(k, val),
387                    None => std::env::remove_var(k),
388                }
389            }
390        }
391        let result = f();
392        for (k, v) in saved {
393            // SAFETY: same guard.
394            unsafe {
395                match v {
396                    Some(val) => std::env::set_var(&k, val),
397                    None => std::env::remove_var(&k),
398                }
399            }
400        }
401        result
402    }
403
404    /// `HJKL_CLIPBOARD=osc52` must force the OSC 52 backend on every platform,
405    /// bypassing the native probe (e.g. the shared macOS pasteboard).
406    #[test]
407    fn env_override_forces_osc52_backend() {
408        let is_osc = with_env(&[("HJKL_CLIPBOARD", Some("osc52"))], || {
409            Clipboard::new().expect("clipboard construct").is_osc52()
410        });
411        assert!(is_osc, "HJKL_CLIPBOARD=osc52 must force the OSC 52 backend");
412    }
413
414    /// An SSH session (no explicit override) selects OSC 52 so writes reach the
415    /// user's local terminal rather than the remote host's clipboard.
416    #[test]
417    fn ssh_session_selects_osc52() {
418        let is_osc = with_env(
419            &[
420                ("HJKL_CLIPBOARD", None),
421                ("SSH_TTY", Some("/dev/pts/0")),
422                ("SSH_CONNECTION", None),
423                ("SSH_CLIENT", None),
424            ],
425            || Clipboard::new().expect("clipboard construct").is_osc52(),
426        );
427        assert!(is_osc, "SSH session should select the OSC 52 backend");
428    }
429
430    /// An explicit `HJKL_CLIPBOARD` override still wins over the SSH heuristic
431    /// (here forcing OSC 52, which is deterministic on every platform).
432    #[test]
433    fn explicit_override_wins_over_ssh_heuristic() {
434        let is_osc = with_env(
435            &[
436                ("HJKL_CLIPBOARD", Some("osc52")),
437                ("SSH_TTY", Some("/dev/pts/1")),
438            ],
439            || Clipboard::new().expect("clipboard construct").is_osc52(),
440        );
441        assert!(is_osc);
442    }
443
444    #[test]
445    fn is_ssh_session_detects_each_variable() {
446        for var in ["SSH_TTY", "SSH_CONNECTION", "SSH_CLIENT"] {
447            let detected = with_env(
448                &[
449                    ("SSH_TTY", None),
450                    ("SSH_CONNECTION", None),
451                    ("SSH_CLIENT", None),
452                    (var, Some("x")),
453                ],
454                is_ssh_session,
455            );
456            assert!(detected, "{var} should mark the session as SSH");
457        }
458    }
459
460    #[test]
461    fn is_ssh_session_false_without_ssh_vars() {
462        let detected = with_env(
463            &[
464                ("SSH_TTY", None),
465                ("SSH_CONNECTION", None),
466                ("SSH_CLIENT", None),
467            ],
468            is_ssh_session,
469        );
470        assert!(!detected, "no SSH vars → not an SSH session");
471    }
472}