use crate::ClipboardBackend;
use anyhow::Context;
use std::process::Command;
const TEXT_MIME_TYPES: &[&str] = &[
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"TEXT",
"STRING",
];
fn run_wl_paste(extra_args: &[&str], command_desc: &str) -> anyhow::Result<String> {
let list_output = Command::new("wl-paste")
.arg("--list-types")
.args(extra_args)
.output()
.context("failed to run wl-paste --list-types — is wl-clipboard installed?")?;
if !list_output.status.success() {
let stderr = String::from_utf8_lossy(&list_output.stderr);
if is_empty_clipboard_message(&stderr) {
return Ok(String::new());
}
anyhow::bail!("{command_desc} --list-types failed: {stderr}");
}
let types = String::from_utf8_lossy(&list_output.stdout);
let offered: Vec<&str> = types.lines().map(str::trim).collect();
if offered.is_empty() {
return Ok(String::new());
}
let Some(mime) = pick_text_type(&offered) else {
anyhow::bail!(
"{command_desc}: clipboard holds non-text content (offered types: {offered:?})"
);
};
let output = Command::new("wl-paste")
.args(["--no-newline", "--type", mime])
.args(extra_args)
.output()
.context("failed to run wl-paste — is wl-clipboard installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("{command_desc} --type {mime} failed: {stderr}");
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn pick_text_type(offered: &[&str]) -> Option<&'static str> {
TEXT_MIME_TYPES
.iter()
.copied()
.find(|t| offered.contains(t))
}
fn is_empty_clipboard_message(stderr: &str) -> bool {
stderr.to_lowercase().contains("nothing is copied")
}
pub struct WaylandClipboard;
impl ClipboardBackend for WaylandClipboard {
fn get_text(&self) -> anyhow::Result<String> {
run_wl_paste(&[], "wl-paste")
}
fn set_text(&self, text: &str) -> anyhow::Result<()> {
use std::io::Write;
let mut child = Command::new("wl-copy")
.stdin(std::process::Stdio::piped())
.spawn()
.context("failed to run wl-copy — is wl-clipboard installed?")?;
if let Some(ref mut stdin) = child.stdin {
stdin
.write_all(text.as_bytes())
.context("failed to write to wl-copy stdin")?;
}
let status = child.wait().context("failed to wait for wl-copy")?;
if !status.success() {
#[cfg(feature = "logging")]
log::warn!("wl-copy exited with status {status}");
}
Ok(())
}
fn get_primary_selection(&self) -> anyhow::Result<String> {
run_wl_paste(&["--primary"], "wl-paste --primary")
}
}
#[cfg(feature = "arboard")]
fn x11_clipboard() -> anyhow::Result<std::sync::MutexGuard<'static, arboard::Clipboard>> {
use std::sync::{Mutex, OnceLock, PoisonError};
static CLIPBOARD: OnceLock<Mutex<arboard::Clipboard>> = OnceLock::new();
if CLIPBOARD.get().is_none() {
let clipboard = arboard::Clipboard::new().context("failed to open X11 clipboard")?;
let _ = CLIPBOARD.set(Mutex::new(clipboard));
}
let clipboard = CLIPBOARD.get().expect("X11 clipboard was just initialized");
Ok(clipboard.lock().unwrap_or_else(PoisonError::into_inner))
}
#[cfg(feature = "arboard")]
fn x11_clipboard_unowned() -> anyhow::Result<bool> {
use x11rb::protocol::xproto::ConnectionExt as _;
let (conn, _screen) = x11rb::connect(None).context("failed to connect to X server")?;
let clipboard = conn.intern_atom(false, b"CLIPBOARD")?.reply()?.atom;
let owner = conn.get_selection_owner(clipboard)?.reply()?.owner;
Ok(owner == x11rb::NONE)
}
#[cfg(feature = "arboard")]
fn text_or_empty_if_unowned(
result: Result<String, arboard::Error>,
is_unowned: impl FnOnce() -> anyhow::Result<bool>,
) -> anyhow::Result<String> {
match result {
Err(arboard::Error::ContentNotAvailable) if matches!(is_unowned(), Ok(true)) => {
Ok(String::new())
}
result => result.context("failed to get text from X11 clipboard"),
}
}
#[cfg(feature = "arboard")]
pub struct X11Clipboard;
#[cfg(feature = "arboard")]
impl ClipboardBackend for X11Clipboard {
fn get_text(&self) -> anyhow::Result<String> {
let result = x11_clipboard()?.get_text();
text_or_empty_if_unowned(result, x11_clipboard_unowned)
}
fn set_text(&self, text: &str) -> anyhow::Result<()> {
x11_clipboard()?
.set_text(text)
.context("failed to set text on X11 clipboard")
}
fn get_primary_selection(&self) -> anyhow::Result<String> {
use arboard::GetExtLinux;
x11_clipboard()?
.get()
.clipboard(arboard::LinuxClipboardKind::Primary)
.text()
.context("failed to get text from X11 primary selection")
}
}
#[cfg(not(feature = "arboard"))]
pub struct X11Clipboard;
#[cfg(not(feature = "arboard"))]
impl ClipboardBackend for X11Clipboard {
fn get_text(&self) -> anyhow::Result<String> {
anyhow::bail!("X11Clipboard requires the 'arboard' feature");
}
fn set_text(&self, _text: &str) -> anyhow::Result<()> {
anyhow::bail!("X11Clipboard requires the 'arboard' feature");
}
fn get_primary_selection(&self) -> anyhow::Result<String> {
anyhow::bail!("X11Clipboard requires the 'arboard' feature");
}
}
pub struct NoopClipboard;
impl ClipboardBackend for NoopClipboard {
fn get_text(&self) -> anyhow::Result<String> {
Ok(String::new())
}
fn set_text(&self, _text: &str) -> anyhow::Result<()> {
Ok(())
}
fn get_primary_selection(&self) -> anyhow::Result<String> {
Ok(String::new())
}
}
pub fn default_clipboard() -> Box<dyn ClipboardBackend> {
if std::env::var("WAYLAND_DISPLAY").is_ok() {
Box::new(WaylandClipboard)
} else {
Box::new(X11Clipboard)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_truly_empty_clipboard() {
assert!(is_empty_clipboard_message("Nothing is copied\n"));
assert!(is_empty_clipboard_message("nothing is copied\n"));
}
#[test]
fn does_not_treat_non_text_content_as_empty() {
assert!(!is_empty_clipboard_message(
"Clipboard content is not available as inferred output type \"text/plain\"\n\
Use \"wl-paste --list-types\" to view available types."
));
assert!(!is_empty_clipboard_message(
"Clipboard content is not available as requested type \"text/plain\"\n\
Use \"wl-paste --list-types\" to view available types."
));
}
#[test]
fn does_not_match_stale_no_suitable_type_phrasing() {
assert!(!is_empty_clipboard_message(
"no suitable type of content copied"
));
}
#[test]
fn pick_text_type_none_for_empty_list() {
assert_eq!(pick_text_type(&[]), None);
}
#[test]
fn pick_text_type_none_for_image_only() {
assert_eq!(pick_text_type(&["image/png"]), None);
assert_eq!(
pick_text_type(&["image/png", "application/x-qt-image", "image/bmp"]),
None
);
}
#[test]
fn pick_text_type_none_for_compound_text_only() {
assert_eq!(pick_text_type(&["COMPOUND_TEXT"]), None);
}
#[test]
fn pick_text_type_vte_offer_reads_utf8_plain() {
assert_eq!(
pick_text_type(&["text/plain;charset=utf-8", "UTF8_STRING", "TARGETS"]),
Some("text/plain;charset=utf-8")
);
}
#[test]
fn pick_text_type_legacy_only_offer_reads_utf8_string() {
assert_eq!(
pick_text_type(&["UTF8_STRING", "TARGETS"]),
Some("UTF8_STRING")
);
}
#[test]
fn pick_text_type_follows_preference_not_listing_order() {
assert_eq!(
pick_text_type(&[
"text/plain",
"text/plain;charset=utf-8",
"TEXT",
"STRING",
"UTF8_STRING",
]),
Some("text/plain;charset=utf-8")
);
assert_eq!(
pick_text_type(&["image/png", "text/plain"]),
Some("text/plain")
);
}
#[test]
fn pick_text_type_prefers_text_over_latin1_string() {
assert_eq!(pick_text_type(&["STRING", "TEXT"]), Some("TEXT"));
}
#[test]
fn pick_text_type_reads_every_text_type_offered_alone() {
for &mime in TEXT_MIME_TYPES {
assert_eq!(pick_text_type(&[mime]), Some(mime));
}
}
#[cfg(feature = "arboard")]
mod x11 {
use super::super::text_or_empty_if_unowned;
use std::cell::Cell;
fn run(
result: Result<String, arboard::Error>,
probe: anyhow::Result<bool>,
) -> (anyhow::Result<String>, bool) {
let called = Cell::new(false);
let out = text_or_empty_if_unowned(result, || {
called.set(true);
probe
});
(out, called.get())
}
fn x11_error(out: anyhow::Result<String>) -> anyhow::Error {
let err = out.expect_err("must stay an error");
assert!(format!("{err:#}").contains("failed to get text from X11 clipboard"));
err
}
fn is_content_not_available(err: &anyhow::Error) -> bool {
matches!(
err.downcast_ref::<arboard::Error>(),
Some(arboard::Error::ContentNotAvailable)
)
}
#[test]
fn text_is_returned_without_owner_check() {
let (out, called) = run(Ok("hello".into()), Ok(true));
assert_eq!(out.unwrap(), "hello");
assert!(!called);
}
#[test]
fn content_not_available_with_no_owner_is_empty() {
let (out, called) = run(Err(arboard::Error::ContentNotAvailable), Ok(true));
assert_eq!(out.unwrap(), "");
assert!(called);
}
#[test]
fn content_not_available_with_owner_stays_error() {
let (out, called) = run(Err(arboard::Error::ContentNotAvailable), Ok(false));
assert!(is_content_not_available(&x11_error(out)));
assert!(called);
}
#[test]
fn content_not_available_with_failed_owner_check_stays_error() {
let (out, called) = run(
Err(arboard::Error::ContentNotAvailable),
Err(anyhow::anyhow!("no X server")),
);
assert!(is_content_not_available(&x11_error(out)));
assert!(called);
}
#[test]
fn other_error_stays_error_without_owner_check() {
let (out, called) = run(Err(arboard::Error::ClipboardOccupied), Ok(true));
let err = x11_error(out);
assert!(matches!(
err.downcast_ref::<arboard::Error>(),
Some(arboard::Error::ClipboardOccupied)
));
assert!(!called);
}
}
}