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}