use std::env;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use crate::exit::ClipfError;
use crate::secret::Secret;
use crate::term;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Osc52,
Xclip,
Xsel,
WlCopy,
PbCopy,
ClipExe,
Termux,
}
impl Backend {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"auto" => Some(Self::detect()),
"osc52" | "osc" => Some(Backend::Osc52),
"xclip" => Some(Backend::Xclip),
"xsel" => Some(Backend::Xsel),
"wl" | "wl-copy" | "wayland" => Some(Backend::WlCopy),
"pbcopy" | "macos" => Some(Backend::PbCopy),
"clip.exe" | "clip" | "wsl" => Some(Backend::ClipExe),
"termux" | "termux-clipboard" | "android" => Some(Backend::Termux),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Backend::Osc52 => "osc52",
Backend::Xclip => "xclip",
Backend::Xsel => "xsel",
Backend::WlCopy => "wl",
Backend::PbCopy => "pbcopy",
Backend::ClipExe => "clip.exe",
Backend::Termux => "termux",
}
}
pub fn command(self) -> Option<&'static str> {
match self {
Backend::Osc52 => None,
Backend::Xclip => Some("xclip"),
Backend::Xsel => Some("xsel"),
Backend::WlCopy => Some("wl-copy"),
Backend::PbCopy => Some("pbcopy"),
Backend::ClipExe => Some("clip.exe"),
Backend::Termux => Some("termux-clipboard-set"),
}
}
pub fn detect() -> Self {
if cfg!(target_os = "macos") && which("pbcopy").is_some() {
Backend::PbCopy
} else if (cfg!(windows) || term::is_wsl()) && which("clip.exe").is_some() {
Backend::ClipExe
} else if which("termux-clipboard-set").is_some() {
Backend::Termux
} else if env::var_os("WAYLAND_DISPLAY").is_some() && which("wl-copy").is_some() {
Backend::WlCopy
} else if env::var_os("DISPLAY").is_some() && which("xclip").is_some() {
Backend::Xclip
} else if env::var_os("DISPLAY").is_some() && which("xsel").is_some() {
Backend::Xsel
} else {
Backend::Osc52
}
}
fn copy_args(self) -> &'static [&'static str] {
match self {
Backend::Xclip => &["-selection", "clipboard"],
Backend::Xsel => &["--input", "--clipboard"],
_ => &[],
}
}
fn paste_command(self) -> Option<(&'static str, &'static [&'static str])> {
match self {
Backend::Xclip => Some(("xclip", &["-selection", "clipboard", "-o"])),
Backend::Xsel => Some(("xsel", &["--output", "--clipboard"])),
Backend::WlCopy => Some(("wl-paste", &["-n"])),
Backend::PbCopy => Some(("pbpaste", &[])),
Backend::ClipExe => Some((
"powershell.exe",
&[
"-NoProfile",
"-Command",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-Clipboard -Raw",
],
)),
Backend::Termux => Some(("termux-clipboard-get", &[])),
Backend::Osc52 => None,
}
}
pub fn copy_local(self, data: &[u8]) -> Result<(), ClipfError> {
let cmd = self
.command()
.ok_or_else(|| ClipfError::backend_unavailable("osc52 is not an external command"))?;
let transcoded: Secret;
let payload: &[u8] = if self == Backend::ClipExe {
match std::str::from_utf8(data) {
Ok(s) if !s.is_ascii() => {
transcoded = Secret::from_vec(utf16le_with_bom(s));
&transcoded
}
_ => data,
}
} else {
data
};
let mut child = Command::new(cmd)
.args(self.copy_args())
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| spawn_error(cmd, e))?;
{
let mut stdin = child
.stdin
.take()
.ok_or_else(|| ClipfError::backend_failed(format!("{cmd}: no stdin pipe")))?;
stdin
.write_all(payload)
.map_err(|e| ClipfError::backend_failed(format!("writing to {cmd}: {e}")))?;
}
let status = child
.wait()
.map_err(|e| ClipfError::backend_failed(format!("waiting for {cmd}: {e}")))?;
if status.success() {
Ok(())
} else {
Err(exit_error(cmd, status))
}
}
pub fn paste(self) -> Result<Vec<u8>, ClipfError> {
let (cmd, args) = self.paste_command().ok_or_else(|| {
ClipfError::paste_unsupported(
"reading the clipboard over OSC 52 is not supported by terminals \
(the query form is disabled almost everywhere as a security measure)",
)
})?;
let out = Command::new(cmd)
.args(args)
.output()
.map_err(|e| spawn_error(cmd, e))?;
if out.status.success() {
Ok(out.stdout)
} else {
Err(exit_error(cmd, out.status))
}
}
}
pub fn which(cmd: &str) -> Option<PathBuf> {
if cmd.contains('/') || cmd.contains('\\') {
let p = PathBuf::from(cmd);
return is_executable(&p).then_some(p);
}
let path = env::var_os("PATH")?;
env::split_paths(&path)
.map(|dir| dir.join(cmd))
.find(|p| is_executable(p))
}
fn spawn_error(cmd: &str, e: std::io::Error) -> ClipfError {
ClipfError::backend_unavailable(format!("cannot run {cmd}: {e}"))
}
fn exit_error(cmd: &str, status: std::process::ExitStatus) -> ClipfError {
ClipfError::backend_failed(format!("{cmd} exited with {status}"))
}
fn utf16le_with_bom(s: &str) -> Vec<u8> {
let mut v = Vec::with_capacity(2 + s.len() * 2);
v.extend_from_slice(&[0xff, 0xfe]);
for unit in s.encode_utf16() {
v.extend_from_slice(&unit.to_le_bytes());
}
v
}
fn is_executable(p: &std::path::Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
p.metadata()
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
p.is_file()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_known_names_and_aliases() {
assert_eq!(Backend::parse("osc52"), Some(Backend::Osc52));
assert_eq!(Backend::parse("OSC52"), Some(Backend::Osc52));
assert_eq!(Backend::parse("wl-copy"), Some(Backend::WlCopy));
assert_eq!(Backend::parse("wayland"), Some(Backend::WlCopy));
assert_eq!(Backend::parse("clip"), Some(Backend::ClipExe));
assert_eq!(Backend::parse("termux"), Some(Backend::Termux));
assert_eq!(Backend::parse("nonsense"), None);
}
#[test]
fn names_round_trip_through_parse() {
for b in [
Backend::Osc52,
Backend::Xclip,
Backend::Xsel,
Backend::WlCopy,
Backend::PbCopy,
Backend::ClipExe,
Backend::Termux,
] {
assert_eq!(Backend::parse(b.name()), Some(b), "{}", b.name());
}
}
#[test]
fn x11_backends_target_the_clipboard_selection() {
assert!(Backend::Xclip.copy_args().contains(&"clipboard"));
assert!(Backend::Xsel.copy_args().contains(&"--clipboard"));
}
#[test]
fn osc52_has_no_local_command_and_cannot_be_pasted() {
assert!(Backend::Osc52.command().is_none());
assert_eq!(
Backend::Osc52.copy_local(b"x").unwrap_err().code(),
5, );
assert_eq!(
Backend::Osc52.paste().unwrap_err().code(),
8, );
}
#[test]
fn spawn_failure_is_backend_unavailable() {
let e = spawn_error("nope", std::io::Error::from(std::io::ErrorKind::NotFound));
assert_eq!(e.code(), 5);
assert!(e.to_string().contains("nope"));
}
#[test]
fn nonzero_exit_is_backend_failed() {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(1 << 8)
};
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(1)
};
let e = exit_error("xclip", status);
assert_eq!(e.code(), 6);
assert!(e.to_string().contains("xclip"));
}
#[test]
fn which_finds_a_standard_binary_and_rejects_nonsense() {
#[cfg(unix)]
assert!(which("sh").is_some());
#[cfg(windows)]
assert!(which("cmd.exe").is_some());
assert!(which("definitely-not-a-real-binary-xyz").is_none());
}
#[test]
fn utf16le_bom_encodes_non_ascii() {
assert_eq!(utf16le_with_bom("é"), vec![0xff, 0xfe, 0xe9, 0x00]);
assert_eq!(utf16le_with_bom("A"), vec![0xff, 0xfe, 0x41, 0x00]);
}
#[test]
fn utf16le_bom_encodes_surrogate_pairs() {
assert_eq!(
utf16le_with_bom("\u{1f600}"),
vec![0xff, 0xfe, 0x3d, 0xd8, 0x00, 0xde]
);
}
}