use std::io::{Read, Write};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
const TOOL_TIMEOUT: Duration = Duration::from_secs(1);
pub trait Clipboard {
fn copy(&mut self, text: &str);
fn paste(&mut self) -> Option<String>;
fn set_background(&mut self, _on: bool) {}
fn poll(&mut self) {}
}
pub fn session(background: bool) -> Box<dyn Clipboard> {
if cfg!(test) {
Box::new(Memory::default())
} else {
let mut system = System::new();
system.set_background(background);
Box::new(system)
}
}
pub struct System {
remote: bool,
hung: bool,
copied_by_osc52: bool,
worker: Option<Worker>,
}
struct Worker {
jobs: mpsc::Sender<String>,
done: mpsc::Receiver<Copied>,
pending: usize,
}
struct Copied {
jobs: usize,
run: Run<()>,
text: String,
}
impl Worker {
fn spawn(copy: fn(&str) -> Run<()>) -> Self {
let (jobs, inbox) = mpsc::channel::<String>();
let (outbox, done) = mpsc::channel();
std::thread::spawn(move || {
while let Ok(mut text) = inbox.recv() {
let mut jobs = 1;
while let Ok(newer) = inbox.try_recv() {
text = newer;
jobs += 1;
}
let run = copy(&text);
if outbox.send(Copied { jobs, run, text }).is_err() {
break;
}
}
});
Worker {
jobs,
done,
pending: 0,
}
}
}
impl System {
pub fn new() -> Self {
Self {
remote: env_set("SSH_TTY") || env_set("SSH_CONNECTION"),
hung: false,
copied_by_osc52: false,
worker: None,
}
}
fn programs_usable(&self) -> bool {
!self.remote && !self.hung
}
fn settle(&mut self, run: Run<()>, text: &str) {
if run == Run::Hung {
self.hung = true;
}
self.copied_by_osc52 = run != Run::Done(());
if self.copied_by_osc52 {
osc52(text);
}
}
fn drain(&mut self, wait: bool) {
let deadline = Instant::now() + TOOL_TIMEOUT;
loop {
let Some(worker) = self.worker.as_mut() else {
return;
};
if worker.pending == 0 {
return;
}
let next = if wait {
let left = deadline.saturating_duration_since(Instant::now());
worker.done.recv_timeout(left).ok()
} else {
worker.done.try_recv().ok()
};
let Some(copied) = next else {
return;
};
worker.pending = worker.pending.saturating_sub(copied.jobs);
self.settle(copied.run, &copied.text);
}
}
}
impl Clipboard for System {
fn copy(&mut self, text: &str) {
if !self.programs_usable() {
return self.settle(Run::Missing, text);
}
if let Some(worker) = self.worker.as_mut() {
if worker.jobs.send(text.to_string()).is_ok() {
worker.pending += 1;
return;
}
}
let run = copy_with_programs(text);
self.settle(run, text);
}
fn paste(&mut self) -> Option<String> {
self.drain(true);
if !self.programs_usable() {
return None;
}
let deadline = Instant::now() + TOOL_TIMEOUT;
for tool in tools() {
match run_paste(tool.paste, deadline) {
Run::Missing => continue,
Run::Done(text) => return Some(text),
Run::Failed => return (!self.copied_by_osc52).then(String::new),
Run::Hung => {
self.hung = true;
return None;
}
}
}
None
}
fn set_background(&mut self, on: bool) {
match (on, self.worker.is_some()) {
(true, false) => self.worker = Some(Worker::spawn(copy_with_programs)),
(false, true) => {
self.drain(true);
self.worker = None;
}
_ => {}
}
}
fn poll(&mut self) {
self.drain(false);
}
}
fn copy_with_programs(text: &str) -> Run<()> {
let deadline = Instant::now() + TOOL_TIMEOUT;
for tool in tools() {
match run_copy(tool.copy, text, deadline) {
Run::Missing | Run::Failed => continue,
done_or_hung => return done_or_hung,
}
}
Run::Failed
}
fn env_set(name: &str) -> bool {
std::env::var_os(name).is_some_and(|v| !v.is_empty())
}
struct Tool {
copy: &'static [&'static str],
paste: &'static [&'static str],
}
fn tools() -> Vec<Tool> {
let mut out = Vec::new();
if cfg!(target_os = "macos") {
out.push(Tool {
copy: &["pbcopy"],
paste: &["pbpaste"],
});
}
if env_set("WAYLAND_DISPLAY") {
out.push(Tool {
copy: &["wl-copy"],
paste: &["wl-paste", "--no-newline"],
});
}
if env_set("DISPLAY") {
out.push(Tool {
copy: &["xclip", "-selection", "clipboard"],
paste: &["xclip", "-selection", "clipboard", "-o"],
});
out.push(Tool {
copy: &["xsel", "--clipboard", "--input"],
paste: &["xsel", "--clipboard", "--output"],
});
}
out
}
#[derive(Debug, PartialEq)]
enum Run<T> {
Missing,
Failed,
Hung,
Done(T),
}
fn spawn(argv: &[&str], stdin: Stdio, stdout: Stdio) -> Option<Child> {
let (prog, args) = argv.split_first()?;
Command::new(prog)
.args(args)
.stdin(stdin)
.stdout(stdout)
.stderr(Stdio::null())
.spawn()
.ok()
}
fn finish(mut child: Child, deadline: Instant) -> Run<()> {
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Run::Done(()),
Ok(Some(_)) | Err(_) => return Run::Failed,
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(5)),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Run::Hung;
}
}
}
}
fn run_copy(argv: &[&str], text: &str, deadline: Instant) -> Run<()> {
if Instant::now() >= deadline {
return Run::Missing;
}
let Some(mut child) = spawn(argv, Stdio::piped(), Stdio::null()) else {
return Run::Missing;
};
let Some(mut pipe) = child.stdin.take() else {
return Run::Failed;
};
let (tx, rx) = mpsc::channel();
let bytes = text.as_bytes().to_vec();
std::thread::spawn(move || {
let _ = tx.send(pipe.write_all(&bytes).is_ok());
});
match finish(child, deadline) {
Run::Done(()) => {
let left = deadline.saturating_duration_since(Instant::now());
if rx.recv_timeout(left).unwrap_or(false) {
Run::Done(())
} else {
Run::Failed
}
}
other => other,
}
}
fn run_paste(argv: &[&str], deadline: Instant) -> Run<String> {
if Instant::now() >= deadline {
return Run::Missing;
}
let Some(mut child) = spawn(argv, Stdio::null(), Stdio::piped()) else {
return Run::Missing;
};
let Some(mut pipe) = child.stdout.take() else {
return Run::Failed;
};
let reader = std::thread::spawn(move || {
let mut bytes = Vec::new();
pipe.read_to_end(&mut bytes).map(|_| bytes)
});
match finish(child, deadline) {
Run::Done(()) => {
let text = reader
.join()
.ok()
.and_then(Result::ok)
.and_then(|bytes| String::from_utf8(bytes).ok())
.map(|s| normalize(&s));
Run::Done(text.unwrap_or_default())
}
Run::Failed => Run::Failed,
Run::Hung => Run::Hung,
Run::Missing => Run::Missing,
}
}
fn osc52(text: &str) {
let mut out = std::io::stdout();
let _ = write!(out, "\x1b]52;c;{}\x07", crate::image::base64(text.as_bytes()));
let _ = out.flush();
}
pub fn normalize(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
#[derive(Clone, Default)]
pub struct Memory(std::rc::Rc<std::cell::RefCell<Option<String>>>);
#[cfg(test)]
impl Memory {
pub fn holding(text: &str) -> Self {
let m = Self::default();
m.0.replace(Some(text.to_string()));
m
}
pub fn text(&self) -> Option<String> {
self.0.borrow().clone()
}
}
impl Clipboard for Memory {
fn copy(&mut self, text: &str) {
self.0.replace(Some(text.to_string()));
}
fn paste(&mut self) -> Option<String> {
self.0.borrow().clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn line_endings_become_newlines() {
assert_eq!(normalize("a\r\nb\rc\n"), "a\nb\nc\n");
}
#[test]
fn a_missing_program_is_reported_missing_not_a_panic() {
let deadline = Instant::now() + TOOL_TIMEOUT;
assert_eq!(run_copy(&["shoin-no-such-clipboard-tool"], "x", deadline), Run::Missing);
assert_eq!(run_paste(&["shoin-no-such-clipboard-tool"], deadline), Run::Missing);
}
#[cfg(unix)]
#[test]
fn a_copy_program_that_never_reads_times_out() {
let big = "x".repeat(1 << 20);
let started = Instant::now();
let deadline = started + Duration::from_millis(200);
assert_eq!(run_copy(&["sleep", "5"], &big, deadline), Run::Hung);
assert!(started.elapsed() < Duration::from_secs(2));
}
#[cfg(unix)]
#[test]
fn paste_programs_report_failure_text_and_non_text() {
let deadline = Instant::now() + TOOL_TIMEOUT;
assert_eq!(run_paste(&["false"], deadline), Run::Failed);
assert_eq!(run_paste(&["printf", "\\377"], deadline), Run::Done(String::new()));
assert_eq!(run_paste(&["printf", "hi"], deadline), Run::Done("hi".into()));
}
#[test]
fn background_copies_coalesce_and_drain() {
use std::sync::Mutex;
static RAN: Mutex<Vec<String>> = Mutex::new(Vec::new());
fn slow_copy(text: &str) -> Run<()> {
std::thread::sleep(Duration::from_millis(50));
RAN.lock().unwrap().push(text.to_string());
Run::Done(())
}
let mut clip = System {
remote: false,
hung: false,
copied_by_osc52: false,
worker: Some(Worker::spawn(slow_copy)),
};
for text in ["one", "two", "three", "four"] {
clip.copy(text);
}
clip.drain(true);
assert_eq!(clip.worker.as_ref().unwrap().pending, 0);
let ran = RAN.lock().unwrap().clone();
assert_eq!(ran.last().map(String::as_str), Some("four"), "the newest text wins");
assert!(ran.len() < 4, "the burst was coalesced: {ran:?}");
assert!(!clip.copied_by_osc52);
}
}