pub mod capabilities;
pub mod error;
pub mod mime;
pub mod selection;
pub mod uri;
pub mod backend;
pub(crate) mod base64;
pub(crate) mod cf_hdrop;
pub(crate) mod cf_html;
pub(crate) mod dib_png;
pub(crate) mod oneshot;
pub(crate) mod osc52;
pub(crate) mod reply;
pub use backend::Backend;
pub use capabilities::{BackendKind, Capabilities};
pub use error::ClipboardError;
pub use mime::MimeType;
pub use selection::Selection;
pub use uri::Uri;
pub(crate) fn is_ssh_session() -> bool {
std::env::var_os("SSH_TTY").is_some()
|| std::env::var_os("SSH_CONNECTION").is_some()
|| std::env::var_os("SSH_CLIENT").is_some()
}
pub struct Clipboard {
backend: Box<dyn Backend>,
}
impl Clipboard {
pub fn new() -> Result<Self, ClipboardError> {
if let Some(forced) = Self::forced_backend() {
return Ok(forced);
}
if is_ssh_session() {
return Ok(Self::with_backend(Box::new(
backend::osc52::Osc52Backend::new(),
)));
}
Self::probe()
}
fn forced_backend() -> Option<Self> {
let raw = std::env::var("HJKL_CLIPBOARD").ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
"osc52" => Some(Self::with_backend(Box::new(
backend::osc52::Osc52Backend::new(),
))),
"native" | "auto" => Self::probe().ok(),
#[cfg(target_os = "linux")]
"wayland" => backend::wayland_backend::WaylandBackend::new()
.ok()
.map(|b| Self::with_backend(Box::new(b))),
#[cfg(target_os = "linux")]
"x11" => backend::x11_backend::X11Backend::new()
.ok()
.map(|b| Self::with_backend(Box::new(b))),
#[cfg(target_os = "macos")]
"macos" => Some(Self::with_backend(Box::new(
backend::macos::MacosBackend::new(),
))),
#[cfg(target_os = "windows")]
"windows" => Some(Self::with_backend(Box::new(
backend::windows::WindowsBackend::new(),
))),
_ => None,
}
}
pub fn with_backend(backend: Box<dyn Backend>) -> Self {
Self { backend }
}
#[cfg(target_os = "linux")]
fn probe() -> Result<Self, ClipboardError> {
match backend::wayland_backend::WaylandBackend::new() {
Ok(b) => return Ok(Self::with_backend(Box::new(b))),
Err(ClipboardError::LibNotFound)
| Err(ClipboardError::NoDisplay)
| Err(ClipboardError::FocusRequired) => {}
Err(e) => return Err(e),
}
match backend::x11_backend::X11Backend::new() {
Ok(b) => return Ok(Self::with_backend(Box::new(b))),
Err(ClipboardError::LibNotFound) | Err(ClipboardError::NoDisplay) => {}
Err(e) => return Err(e),
}
Ok(Self::with_backend(Box::new(
backend::osc52::Osc52Backend::new(),
)))
}
#[cfg(target_os = "macos")]
fn probe() -> Result<Self, ClipboardError> {
Ok(Self::with_backend(Box::new(
backend::macos::MacosBackend::new(),
)))
}
#[cfg(target_os = "windows")]
fn probe() -> Result<Self, ClipboardError> {
Ok(Self::with_backend(Box::new(
backend::windows::WindowsBackend::new(),
)))
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn probe() -> Result<Self, ClipboardError> {
Ok(Self::with_backend(Box::new(
backend::osc52::Osc52Backend::new(),
)))
}
pub fn kind(&self) -> BackendKind {
self.backend.kind()
}
pub fn capabilities(&self) -> Capabilities {
self.backend.capabilities()
}
pub fn backend_name(&self) -> &'static str {
self.backend.kind().as_str()
}
pub fn set(&self, sel: Selection, mime: MimeType, bytes: &[u8]) -> Result<(), ClipboardError> {
self.backend.set(sel, mime, bytes)
}
pub fn get(&self, sel: Selection, mime: MimeType) -> Result<Vec<u8>, ClipboardError> {
self.backend.get(sel, mime)
}
pub fn clear(&self, sel: Selection) -> Result<(), ClipboardError> {
self.backend.clear(sel)
}
pub fn available(&self, sel: Selection) -> Result<Vec<MimeType>, ClipboardError> {
self.backend.available(sel)
}
pub async fn set_async(
&self,
sel: Selection,
mime: MimeType,
bytes: &[u8],
) -> Result<(), ClipboardError> {
self.backend.set_async(sel, mime, bytes.to_vec()).await
}
pub async fn get_async(
&self,
sel: Selection,
mime: MimeType,
) -> Result<Vec<u8>, ClipboardError> {
self.backend.get_async(sel, mime).await
}
pub async fn clear_async(&self, sel: Selection) -> Result<(), ClipboardError> {
self.backend.clear_async(sel).await
}
pub async fn available_async(&self, sel: Selection) -> Result<Vec<MimeType>, ClipboardError> {
self.backend.available_async(sel).await
}
pub fn set_uri_list(&self, sel: Selection, uris: &[Uri]) -> Result<(), ClipboardError> {
let bytes = crate::uri::encode_uri_list(uris)?;
self.set(sel, MimeType::UriList, &bytes)
}
pub fn get_uri_list(&self, sel: Selection) -> Result<Vec<Uri>, ClipboardError> {
let bytes = self.get(sel, MimeType::UriList)?;
crate::uri::decode_uri_list(&bytes)
}
#[cfg(test)]
pub(crate) fn is_osc52(&self) -> bool {
self.backend.kind() == BackendKind::Osc52
}
}
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::*;
#[test]
fn backend_name_returns_valid_string() {
let valid = ["wayland", "x11", "macos", "windows", "osc52"];
if let Ok(cb) = Clipboard::new() {
assert!(
valid.contains(&cb.backend_name()),
"unexpected backend_name: {}",
cb.backend_name()
);
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
#[test]
fn osc52_backend_set_and_get() {
use backend::osc52::Osc52Backend;
use osc52::is_in_tmux;
let b = Osc52Backend::new();
let mut buf = Vec::new();
b.set_inner(Selection::Clipboard, MimeType::Text, b"hello", &mut buf)
.expect("set_inner failed");
let seq = std::str::from_utf8(&buf).expect("output not UTF-8");
let body = if is_in_tmux() {
assert!(
seq.starts_with("\x1bPtmux;\x1b\x1b]52;c;"),
"wrong DCS prefix: {seq:?}"
);
assert!(seq.ends_with("\x07\x1b\\"), "wrong DCS suffix: {seq:?}");
seq.strip_prefix("\x1bPtmux;\x1b\x1b]52;c;")
.unwrap()
.strip_suffix("\x07\x1b\\")
.unwrap()
} else {
assert!(seq.starts_with("\x1b]52;c;"), "wrong OSC prefix: {seq:?}");
assert!(seq.ends_with('\x07'), "wrong BEL suffix: {seq:?}");
seq.strip_prefix("\x1b]52;c;")
.unwrap()
.strip_suffix('\x07')
.unwrap()
};
assert_eq!(body, "aGVsbG8=", "base64 mismatch for 'hello'");
let cb = Clipboard::with_backend(Box::new(Osc52Backend::new()));
assert!(cb.is_osc52(), "expected Osc52 backend");
let err = cb.get(Selection::Clipboard, MimeType::Text).unwrap_err();
assert!(
matches!(err, ClipboardError::UnsupportedMime),
"expected UnsupportedMime from osc52 get, got: {err}"
);
let mimes = cb.available(Selection::Clipboard).unwrap();
assert!(mimes.is_empty(), "expected empty available from osc52");
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let saved: Vec<(String, Option<String>)> = vars
.iter()
.map(|(k, _)| ((*k).to_string(), std::env::var(k).ok()))
.collect();
for (k, v) in vars {
unsafe {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
let result = f();
for (k, v) in saved {
unsafe {
match v {
Some(val) => std::env::set_var(&k, val),
None => std::env::remove_var(&k),
}
}
}
result
}
#[test]
fn env_override_forces_osc52_backend() {
let is_osc = with_env(&[("HJKL_CLIPBOARD", Some("osc52"))], || {
Clipboard::new().expect("clipboard construct").is_osc52()
});
assert!(is_osc, "HJKL_CLIPBOARD=osc52 must force the OSC 52 backend");
}
#[test]
fn ssh_session_selects_osc52() {
let is_osc = with_env(
&[
("HJKL_CLIPBOARD", None),
("SSH_TTY", Some("/dev/pts/0")),
("SSH_CONNECTION", None),
("SSH_CLIENT", None),
],
|| Clipboard::new().expect("clipboard construct").is_osc52(),
);
assert!(is_osc, "SSH session should select the OSC 52 backend");
}
#[test]
fn explicit_override_wins_over_ssh_heuristic() {
let is_osc = with_env(
&[
("HJKL_CLIPBOARD", Some("osc52")),
("SSH_TTY", Some("/dev/pts/1")),
],
|| Clipboard::new().expect("clipboard construct").is_osc52(),
);
assert!(is_osc);
}
#[test]
fn is_ssh_session_detects_each_variable() {
for var in ["SSH_TTY", "SSH_CONNECTION", "SSH_CLIENT"] {
let detected = with_env(
&[
("SSH_TTY", None),
("SSH_CONNECTION", None),
("SSH_CLIENT", None),
(var, Some("x")),
],
is_ssh_session,
);
assert!(detected, "{var} should mark the session as SSH");
}
}
#[test]
fn is_ssh_session_false_without_ssh_vars() {
let detected = with_env(
&[
("SSH_TTY", None),
("SSH_CONNECTION", None),
("SSH_CLIENT", None),
],
is_ssh_session,
);
assert!(!detected, "no SSH vars → not an SSH session");
}
}