Skip to main content

rig_tools/
clipboard.rs

1//! The system clipboard, read and written by the programs that own it on
2//! this machine: `wl-paste`/`wl-copy` on Wayland, `xclip` on X11, and
3//! `pbpaste`/`pbcopy` (`pngpaste` for images) on macOS. Each is started as
4//! a [`Launcher`] says, such as one that keeps the session's secrets from
5//! it. Blocking: call these off a thread that must not wait.
6
7use std::io::{self, ErrorKind, Write as _};
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use rig_core::message::ImageMediaType;
13
14use crate::process::Launcher;
15
16/// What a clipboard program does.
17#[derive(Clone, Copy, PartialEq, Eq)]
18enum Use {
19    /// Prints the clipboard's text.
20    ReadText,
21    /// Prints the clipboard's image as PNG.
22    ReadImage,
23    /// Replaces the clipboard's text with its input.
24    WriteText,
25}
26
27/// The programs that do `what` on this machine, in the order tried.
28fn programs(what: Use) -> Vec<(&'static str, &'static [&'static str])> {
29    let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
30    let x11 = std::env::var_os("DISPLAY").is_some();
31    let mac = cfg!(target_os = "macos");
32    let all: [(bool, Use, &'static str, &'static [&'static str]); 9] = [
33        (wayland, Use::ReadText, "wl-paste", &["--no-newline"]),
34        (
35            wayland,
36            Use::ReadImage,
37            "wl-paste",
38            &["--no-newline", "--type", "image/png"],
39        ),
40        (wayland, Use::WriteText, "wl-copy", &[]),
41        (
42            x11,
43            Use::ReadText,
44            "xclip",
45            &["-selection", "clipboard", "-out"],
46        ),
47        (
48            x11,
49            Use::ReadImage,
50            "xclip",
51            &["-selection", "clipboard", "-target", "image/png", "-out"],
52        ),
53        (
54            x11,
55            Use::WriteText,
56            "xclip",
57            &["-selection", "clipboard", "-in"],
58        ),
59        (mac, Use::ReadText, "pbpaste", &[]),
60        (mac, Use::ReadImage, "pngpaste", &["-"]),
61        (mac, Use::WriteText, "pbcopy", &[]),
62    ];
63    all.into_iter()
64        .filter(|(here, kind, _, _)| *here && *kind == what)
65        .map(|(_, _, program, args)| (program, args))
66        .collect()
67}
68
69/// The error for a clipboard none of `programs` could serve.
70fn none_of(programs: &[(&str, &[&str])], what: &str) -> io::Error {
71    if programs.is_empty() {
72        return io::Error::new(
73            ErrorKind::Unsupported,
74            "no clipboard here: it needs wl-paste and wl-copy, xclip, or pbpaste and pbcopy",
75        );
76    }
77    let names: Vec<&str> = programs.iter().map(|(program, _)| *program).collect();
78    io::Error::new(
79        ErrorKind::NotFound,
80        format!(
81            "no {what} in the clipboard (read with {})",
82            names.join(", ")
83        ),
84    )
85}
86
87/// What the first program for `what` that succeeds prints.
88fn output_of(
89    launcher: &Launcher,
90    what: Use,
91) -> (
92    Vec<(&'static str, &'static [&'static str])>,
93    Option<Vec<u8>>,
94) {
95    let programs = programs(what);
96    let printed = programs.iter().find_map(|(program, args)| {
97        let output = launcher
98            .command(program)
99            .args(*args)
100            .stdin(Stdio::null())
101            .stderr(Stdio::null())
102            .output()
103            .ok()?;
104        output.status.success().then_some(output.stdout)
105    });
106    (programs, printed)
107}
108
109/// The clipboard's text.
110///
111/// # Errors
112///
113/// [`Unsupported`](ErrorKind::Unsupported) when this machine has no
114/// clipboard program, and [`NotFound`](ErrorKind::NotFound) when none of
115/// them gave text.
116pub fn read_text(launcher: &Launcher) -> io::Result<String> {
117    match output_of(launcher, Use::ReadText) {
118        (_, Some(bytes)) => Ok(String::from_utf8_lossy(&bytes).into_owned()),
119        (programs, None) => Err(none_of(&programs, "text")),
120    }
121}
122
123/// Saves the clipboard's image as a file in the directory `into`, named by
124/// the time and the image's type, and returns its path; `None` when the
125/// clipboard holds no image.
126///
127/// # Errors
128///
129/// [`Unsupported`](ErrorKind::Unsupported) when this machine has no
130/// clipboard program, or the file could not be saved.
131pub fn read_image(launcher: &Launcher, into: &Path) -> io::Result<Option<PathBuf>> {
132    let (programs, printed) = output_of(launcher, Use::ReadImage);
133    if programs.is_empty() {
134        return Err(none_of(&programs, "image"));
135    }
136    let Some((bytes, kind)) =
137        printed.and_then(|bytes| ImageMediaType::sniff(&bytes).map(|kind| (bytes, kind)))
138    else {
139        return Ok(None);
140    };
141    std::fs::create_dir_all(into)?;
142    let stamp = SystemTime::now()
143        .duration_since(UNIX_EPOCH)
144        .map(|elapsed| elapsed.as_millis())
145        .unwrap_or_default();
146    let path = into.join(format!("pasted-{stamp}.{}", kind.extension()));
147    std::fs::write(&path, bytes)?;
148    Ok(Some(path))
149}
150
151/// Replaces the clipboard's text with `text`.
152///
153/// # Errors
154///
155/// [`Unsupported`](ErrorKind::Unsupported) when this machine has no
156/// clipboard program, else why the last one tried failed.
157pub fn write_text(launcher: &Launcher, text: &str) -> io::Result<()> {
158    let programs = programs(Use::WriteText);
159    let mut failed = none_of(&[], "");
160    for (program, args) in programs {
161        let written = launcher
162            .command(program)
163            .args(args)
164            .stdin(Stdio::piped())
165            .stdout(Stdio::null())
166            .stderr(Stdio::null())
167            .spawn()
168            .and_then(|mut child| {
169                if let Some(mut stdin) = child.stdin.take() {
170                    stdin.write_all(text.as_bytes())?;
171                }
172                let status = child.wait()?;
173                match status.success() {
174                    true => Ok(()),
175                    false => Err(io::Error::other(format!("{program} failed: {status}"))),
176                }
177            });
178        match written {
179            Ok(()) => return Ok(()),
180            Err(error) => failed = error,
181        }
182    }
183    Err(failed)
184}