use crate::ClipboardBackend;
use anyhow::Context;
use std::process::Command;
const TEXT_MIME_TYPES: &[&str] = &[
"text/plain",
"text/plain;charset=utf-8",
"TEXT",
"STRING",
"UTF8_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());
}
if !offers_text(&offered) {
anyhow::bail!(
"{command_desc}: clipboard holds non-text content (offered types: {offered:?})"
);
}
let output = Command::new("wl-paste")
.args(["--no-newline", "--type", "text/plain"])
.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} failed: {stderr}");
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn offers_text(offered: &[&str]) -> bool {
offered.iter().any(|t| TEXT_MIME_TYPES.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")]
pub struct X11Clipboard;
#[cfg(feature = "arboard")]
impl ClipboardBackend for X11Clipboard {
fn get_text(&self) -> anyhow::Result<String> {
let mut clipboard = arboard::Clipboard::new().context("failed to open X11 clipboard")?;
clipboard
.get_text()
.context("failed to get text from X11 clipboard")
}
fn set_text(&self, text: &str) -> anyhow::Result<()> {
let mut clipboard = arboard::Clipboard::new().context("failed to open X11 clipboard")?;
clipboard
.set_text(text)
.context("failed to set text on X11 clipboard")
}
fn get_primary_selection(&self) -> anyhow::Result<String> {
use arboard::GetExtLinux;
let mut clipboard = arboard::Clipboard::new().context("failed to open X11 clipboard")?;
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 offers_text_true_for_plain_text_types() {
assert!(offers_text(&["text/plain"]));
assert!(offers_text(&["text/plain;charset=utf-8"]));
assert!(offers_text(&["TEXT"]));
assert!(offers_text(&["STRING"]));
assert!(offers_text(&["UTF8_STRING"]));
assert!(offers_text(&["image/png", "text/plain"]));
}
#[test]
fn offers_text_false_for_image_only() {
assert!(!offers_text(&["image/png"]));
assert!(!offers_text(&[
"image/png",
"application/x-qt-image",
"image/bmp"
]));
}
#[test]
fn offers_text_false_for_empty_list() {
assert!(!offers_text(&[]));
}
}