use std::io;
#[cfg(unix)]
use std::sync::OnceLock;
pub(crate) const MAX_CLIPBOARD_BYTES: usize = 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub(crate) enum ClipboardError {
#[error(
"clipboard provider not available (install xclip / wl-paste / xsel, \
or use WSL with powershell.exe in PATH)"
)]
NoProvider,
#[error("clipboard read timed out after 500 ms")]
Timeout,
#[error("clipboard text exceeds maximum size of {cap} bytes ({actual} bytes)")]
TooLarge { actual: usize, cap: usize },
#[error("clipboard text is not valid UTF-8 / UTF-16")]
Decode,
#[error("OS error: {0}")]
Io(#[from] io::Error),
}
pub(crate) fn read_clipboard_text() -> Result<String, ClipboardError> {
let raw = read_raw()?;
if raw.len() > MAX_CLIPBOARD_BYTES {
return Err(ClipboardError::TooLarge {
actual: raw.len(),
cap: MAX_CLIPBOARD_BYTES,
});
}
Ok(raw)
}
#[cfg(windows)]
fn read_raw() -> Result<String, ClipboardError> {
use std::ptr;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::System::DataExchange::{
CloseClipboard, GetClipboardData, OpenClipboard,
};
use windows_sys::Win32::System::Memory::{GlobalLock, GlobalUnlock};
const CF_UNICODETEXT: u32 = 13;
struct ClipboardGuard;
impl Drop for ClipboardGuard {
fn drop(&mut self) {
unsafe { CloseClipboard() };
}
}
let opened = unsafe { OpenClipboard(0 as _) };
if opened == 0 {
return Err(ClipboardError::NoProvider);
}
let _guard = ClipboardGuard;
let handle: HANDLE = unsafe { GetClipboardData(CF_UNICODETEXT) };
if handle.is_null() {
return Ok(String::new());
}
let locked = unsafe { GlobalLock(handle as _) } as *const u16;
if locked.is_null() {
return Err(ClipboardError::Io(io::Error::new(
io::ErrorKind::Other,
"GlobalLock failed",
)));
}
let mut len = 0usize;
let cap_u16 = MAX_CLIPBOARD_BYTES / 2;
while len < cap_u16 && unsafe { *locked.add(len) } != 0 {
len += 1;
}
let slice = unsafe { std::slice::from_raw_parts(locked, len) };
let result = String::from_utf16(slice).map_err(|_| ClipboardError::Decode);
unsafe { GlobalUnlock(handle as _) };
result
}
#[cfg(unix)]
fn read_raw() -> Result<String, ClipboardError> {
let mut last_err: Option<ClipboardError> = None;
for provider in providers() {
match try_provider(provider) {
Ok(text) => return Ok(strip_bom(text)),
Err(ClipboardError::NoProvider) => continue, Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or(ClipboardError::NoProvider))
}
#[cfg(unix)]
fn providers() -> Vec<(&'static str, Vec<&'static str>)> {
let mut chain: Vec<(&'static str, Vec<&'static str>)> = Vec::new();
if cfg!(target_os = "macos") {
chain.push(("pbpaste", vec![]));
return chain;
}
chain.push(("wl-paste", vec!["--no-newline"]));
chain.push(("xclip", vec!["-selection", "clipboard", "-o"]));
chain.push(("xsel", vec!["--clipboard", "--output"]));
if is_wsl() {
chain.push(("powershell.exe", vec!["-NoProfile", "-Command", "Get-Clipboard"]));
}
chain
}
#[cfg(unix)]
fn is_wsl() -> bool {
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|s| s.to_lowercase().contains("microsoft"))
.unwrap_or(false)
})
}
#[cfg(unix)]
fn try_provider(provider: (&str, Vec<&str>)) -> Result<String, ClipboardError> {
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let (cmd, args) = provider;
let mut child = match Command::new(cmd)
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(ClipboardError::NoProvider);
}
Err(e) => return Err(ClipboardError::Io(e)),
};
let deadline = Instant::now() + Duration::from_millis(500);
loop {
match child.try_wait()? {
Some(_status) => break,
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return Err(ClipboardError::Timeout);
}
None => std::thread::sleep(Duration::from_millis(10)),
}
}
let output = child.wait_with_output()?;
if !output.status.success() {
if cmd == "wl-paste" {
return Ok(String::new());
}
return Err(ClipboardError::Io(io::Error::new(
io::ErrorKind::Other,
format!("{cmd} exited with status {}", output.status),
)));
}
decode(&output.stdout)
}
#[cfg(unix)]
fn decode(bytes: &[u8]) -> Result<String, ClipboardError> {
if bytes.starts_with(&[0xFF, 0xFE]) {
let payload = &bytes[2..];
if payload.len() % 2 != 0 {
return Err(ClipboardError::Decode);
}
let mut units = Vec::with_capacity(payload.len() / 2);
for chunk in payload.chunks_exact(2) {
units.push(u16::from_le_bytes([chunk[0], chunk[1]]));
}
return String::from_utf16(&units).map_err(|_| ClipboardError::Decode);
}
String::from_utf8(bytes.to_vec()).map_err(|_| ClipboardError::Decode)
}
#[cfg(unix)]
fn strip_bom(mut s: String) -> String {
if s.starts_with('\u{FEFF}') {
s.replace_range(..'\u{FEFF}'.len_utf8(), "");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn max_bytes_is_one_mib() {
assert_eq!(MAX_CLIPBOARD_BYTES, 1024 * 1024);
}
#[test]
fn too_large_error_includes_actual_and_cap() {
let e = ClipboardError::TooLarge {
actual: 2 * 1024 * 1024,
cap: MAX_CLIPBOARD_BYTES,
};
let msg = format!("{e}");
assert!(msg.contains("2097152"));
assert!(msg.contains("1048576"));
}
#[cfg(unix)]
#[test]
fn strip_bom_removes_leading_utf8_bom() {
assert_eq!(strip_bom("\u{FEFF}hello".to_string()), "hello");
}
#[cfg(unix)]
#[test]
fn strip_bom_preserves_text_without_bom() {
assert_eq!(strip_bom("hello".to_string()), "hello");
}
#[cfg(unix)]
#[test]
fn decode_handles_utf8() {
assert_eq!(decode(b"hello").unwrap(), "hello");
}
#[cfg(unix)]
#[test]
fn decode_handles_utf16le_bom() {
let bytes = vec![0xFF, 0xFE, 0x68, 0x00, 0x69, 0x00];
assert_eq!(decode(&bytes).unwrap(), "hi");
}
#[cfg(unix)]
#[test]
fn decode_rejects_truncated_utf16le_bom() {
let bytes = vec![0xFF, 0xFE, 0x68, 0x00, 0x69];
assert!(matches!(decode(&bytes), Err(ClipboardError::Decode)));
}
}