Skip to main content

qframe/runtime/
clipboard.rs

1//! The clipboard: notifications for applications and reading what the user copied.
2//!
3//! Reading tries three sources in order and takes the first that has text:
4//!
5//! 1. **The system clipboard tool**: `wl-paste` on Wayland, `xclip` or `xsel` on X11, `pbpaste`
6//!    on macOS. Each runs without a shell and is stopped after a short timeout; on a runtime it
7//!    runs on its own thread, so drawing never waits for it.
8//! 2. **The terminal**, asked with an OSC 52 clipboard query. Many terminals refuse or ignore it,
9//!    so the runtime waits only briefly for the answer.
10//! 3. **The text this application copied last.**
11
12use std::io::Read;
13use std::process::{Command, Stdio};
14use std::sync::mpsc::{self, Receiver, TryRecvError};
15use std::time::{Duration, Instant};
16
17/// Something that happened on the clipboard, delivered to
18/// [`App::clipboard`](crate::runtime::App::clipboard).
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ClipboardEvent {
21    /// A widget or a mouse selection copied this text.
22    Copied(String),
23    /// This text was pasted (from the terminal, with the `paste` key or from a Paste menu entry)
24    /// and no focused widget took it.
25    Pasted(String),
26}
27
28/// How long a clipboard tool may run before it is stopped.
29pub(crate) const TOOL_TIMEOUT: Duration = Duration::from_millis(500);
30
31/// How often a running tool is checked for having finished.
32const TOOL_POLL: Duration = Duration::from_millis(5);
33
34/// A clipboard program and its arguments, run without a shell.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub(crate) struct Tool {
37    program: String,
38    args: Vec<String>,
39}
40
41impl Tool {
42    pub(crate) fn new(program: &str, args: &[&str]) -> Self {
43        Self { program: program.to_owned(), args: args.iter().map(|arg| (*arg).to_owned()).collect() }
44    }
45
46    /// Runs the tool and returns what it printed, when it succeeds within `timeout` with text.
47    /// A tool that is missing, fails, prints nothing or something that is not UTF-8, or runs too
48    /// long gives `None`; a tool that runs too long is killed.
49    pub(crate) fn read(&self, timeout: Duration) -> Option<String> {
50        let mut child = Command::new(&self.program)
51            .args(&self.args)
52            .stdin(Stdio::null())
53            .stdout(Stdio::piped())
54            .stderr(Stdio::null())
55            .spawn()
56            .ok()?;
57        // Read while waiting: a large clipboard would otherwise fill the pipe and stall the tool.
58        let mut stdout = child.stdout.take()?;
59        // A system that cannot start another thread cannot run the tool safely either.
60        let Ok(output) = std::thread::Builder::new().name("quvyta-clipboard-pipe".to_owned()).spawn(move || {
61            let mut output = Vec::new();
62            stdout.read_to_end(&mut output).map(|_| output)
63        }) else {
64            // Nothing is read, so the tool has nothing left to say and the answer is already
65            // `None`. The kill fails only on a tool that ended by itself, and the wait is here
66            // to collect it rather than to be checked: a reading that failed must not also leave
67            // a process behind.
68            let _ = child.kill();
69            let _ = child.wait();
70            return None;
71        };
72        let started = Instant::now();
73        let status = loop {
74            match child.try_wait() {
75                Ok(Some(status)) => break status,
76                Ok(None) if started.elapsed() < timeout => std::thread::sleep(TOOL_POLL),
77                _ => {
78                    // The tool ran past its time or could not be waited on: either way the
79                    // clipboard has no answer, and the next source is tried. As above, the kill
80                    // and the wait are how the tool is cleared away, not a question being asked.
81                    let _ = child.kill();
82                    let _ = child.wait();
83                    return None;
84                }
85            }
86        };
87        let text = String::from_utf8(output.join().ok()?.ok()?).ok()?;
88        (status.success() && !text.is_empty()).then_some(text)
89    }
90}
91
92/// The clipboard tools to try on this system, in order. `var` tells whether an environment
93/// variable is set.
94pub(crate) fn platform_tools(var: impl Fn(&str) -> bool) -> Vec<Tool> {
95    if cfg!(target_os = "macos") {
96        return vec![Tool::new("pbpaste", &[])];
97    }
98    let mut tools = Vec::new();
99    if var("WAYLAND_DISPLAY") {
100        tools.push(Tool::new("wl-paste", &["--no-newline", "--type", "text"]));
101    }
102    if var("DISPLAY") {
103        tools.push(Tool::new("xclip", &["-o", "-selection", "clipboard"]));
104        tools.push(Tool::new("xsel", &["--clipboard", "--output"]));
105    }
106    tools
107}
108
109/// Where the first source, the system clipboard, comes from.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub(crate) enum SystemClipboard {
112    /// The platform's clipboard programs, tried in order.
113    Tools(Vec<Tool>),
114    /// A fixed answer: the test harness never touches the real clipboard.
115    Fixed(Option<String>),
116}
117
118/// What reading the clipboard needs next.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub(crate) enum ReadStep {
121    /// The system tool is still running on its thread.
122    Waiting,
123    /// The system clipboard gave nothing: ask the terminal with OSC 52 and pass its answer to
124    /// [`ClipboardReader::terminal_answer`].
125    AskTerminal,
126    /// Reading ended; `None` when neither the system nor the terminal had text, so the text the
127    /// application copied last is used.
128    Done(Option<String>),
129}
130
131/// Reads the clipboard from the system tool, then the terminal; see the module docs.
132pub(crate) struct ClipboardReader {
133    system: SystemClipboard,
134    terminal: bool,
135    /// Whether the tools run on a thread (a runtime) or inline.
136    threaded: bool,
137    state: ReadState,
138}
139
140enum ReadState {
141    Idle,
142    System(Receiver<Option<String>>),
143    Terminal,
144}
145
146impl ClipboardReader {
147    /// A reader for a runtime: the platform's tools on a thread, then the terminal.
148    pub(crate) fn runtime() -> Self {
149        let tools = platform_tools(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()));
150        Self { system: SystemClipboard::Tools(tools), terminal: true, threaded: true, state: ReadState::Idle }
151    }
152
153    /// A reader for tests: a fixed system clipboard that starts empty and no terminal.
154    pub(crate) fn fixed() -> Self {
155        Self { system: SystemClipboard::Fixed(None), terminal: false, threaded: false, state: ReadState::Idle }
156    }
157
158    /// Replaces the system clipboard source.
159    pub(crate) fn set_system(&mut self, system: SystemClipboard) {
160        self.system = system;
161    }
162
163    /// Whether a read is under way.
164    pub(crate) fn is_reading(&self) -> bool {
165        !matches!(self.state, ReadState::Idle)
166    }
167
168    /// Starts reading. Call only while no read is under way.
169    pub(crate) fn start(&mut self) -> ReadStep {
170        match &self.system {
171            SystemClipboard::Fixed(text) => self.after_system(text.clone()),
172            SystemClipboard::Tools(tools) if self.threaded => {
173                let tools = tools.clone();
174                let (sender, receiver) = mpsc::channel();
175                let spawned = std::thread::Builder::new().name("quvyta-clipboard".to_owned()).spawn(move || {
176                    // The send fails only when nobody is waiting for the answer any more: the
177                    // read was let go, or the application is ending. There is then no clipboard
178                    // left to hand anything to.
179                    let _ = sender.send(tools.iter().find_map(|tool| tool.read(TOOL_TIMEOUT)));
180                });
181                if spawned.is_err() {
182                    // No thread to wait on: skip the system tools and go on to the next source.
183                    return self.after_system(None);
184                }
185                self.state = ReadState::System(receiver);
186                ReadStep::Waiting
187            }
188            SystemClipboard::Tools(tools) => {
189                let text = tools.iter().find_map(|tool| tool.read(TOOL_TIMEOUT));
190                self.after_system(text)
191            }
192        }
193    }
194
195    /// Checks on a system tool running on its thread.
196    pub(crate) fn poll(&mut self) -> ReadStep {
197        let ReadState::System(receiver) = &self.state else {
198            return ReadStep::Waiting;
199        };
200        match receiver.try_recv() {
201            Ok(text) => self.after_system(text),
202            Err(TryRecvError::Empty) => ReadStep::Waiting,
203            Err(TryRecvError::Disconnected) => self.after_system(None),
204        }
205    }
206
207    /// The terminal's answer to the OSC 52 query, `None` when it did not answer in time.
208    pub(crate) fn terminal_answer(&mut self, text: Option<String>) -> ReadStep {
209        if !matches!(self.state, ReadState::Terminal) {
210            return ReadStep::Waiting;
211        }
212        self.state = ReadState::Idle;
213        ReadStep::Done(text.filter(|text| !text.is_empty()))
214    }
215
216    fn after_system(&mut self, text: Option<String>) -> ReadStep {
217        if text.is_some() {
218            self.state = ReadState::Idle;
219            return ReadStep::Done(text);
220        }
221        if self.terminal {
222            self.state = ReadState::Terminal;
223            ReadStep::AskTerminal
224        } else {
225            self.state = ReadState::Idle;
226            ReadStep::Done(None)
227        }
228    }
229}
230
231/// The OSC 52 query asking the terminal for its clipboard.
232pub(crate) const OSC52_QUERY: &str = "\x1b]52;c;?\x07";
233
234/// Decodes standard base64, ignoring anything outside the alphabet such as line breaks. Returns
235/// `None` for text that is not valid UTF-8 once decoded.
236pub(crate) fn decode_base64(encoded: &str) -> Option<String> {
237    let value = |c: u8| match c {
238        b'A'..=b'Z' => Some(c - b'A'),
239        b'a'..=b'z' => Some(c - b'a' + 26),
240        b'0'..=b'9' => Some(c - b'0' + 52),
241        b'+' => Some(62),
242        b'/' => Some(63),
243        _ => None,
244    };
245    let mut bytes = Vec::new();
246    let mut buffer = 0u32;
247    let mut bits = 0;
248    for sextet in encoded.bytes().take_while(|c| *c != b'=').filter_map(value) {
249        buffer = (buffer << 6) | u32::from(sextet);
250        bits += 6;
251        if bits >= 8 {
252            bits -= 8;
253            bytes.push(u8::try_from((buffer >> bits) & 0xff).unwrap_or(0));
254        }
255    }
256    String::from_utf8(bytes).ok()
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn fails() -> Tool {
264        Tool::new("quvyta-no-such-clipboard-tool", &[])
265    }
266
267    #[test]
268    fn tools_run_without_a_shell_and_fail_quietly() {
269        assert_eq!(Tool::new("printf", &["%s", "deploy $HOME"]).read(TOOL_TIMEOUT), Some("deploy $HOME".to_owned()));
270        assert_eq!(fails().read(TOOL_TIMEOUT), None, "a missing program");
271        assert_eq!(Tool::new("false", &[]).read(TOOL_TIMEOUT), None, "a failing program");
272        assert_eq!(Tool::new("printf", &[""]).read(TOOL_TIMEOUT), None, "an empty clipboard");
273        let started = Instant::now();
274        assert_eq!(Tool::new("sleep", &["5"]).read(Duration::from_millis(50)), None, "too slow");
275        assert!(started.elapsed() < Duration::from_secs(2), "the slow tool was stopped");
276    }
277
278    #[test]
279    fn platform_tools_follow_the_display_server() {
280        if cfg!(target_os = "macos") {
281            return;
282        }
283        let names = |vars: &[&str]| {
284            platform_tools(|name| vars.contains(&name)).into_iter().map(|tool| tool.program).collect::<Vec<_>>()
285        };
286        assert_eq!(names(&["WAYLAND_DISPLAY"]), ["wl-paste"]);
287        assert_eq!(names(&["DISPLAY"]), ["xclip", "xsel"]);
288        assert_eq!(names(&["WAYLAND_DISPLAY", "DISPLAY"]), ["wl-paste", "xclip", "xsel"]);
289        assert!(names(&[]).is_empty(), "over SSH without a display there is no tool");
290    }
291
292    #[test]
293    fn sources_are_tried_in_order() {
294        let reader = |tools: Vec<Tool>, terminal: bool| ClipboardReader {
295            system: SystemClipboard::Tools(tools),
296            terminal,
297            threaded: false,
298            state: ReadState::Idle,
299        };
300        let mut system = reader(vec![fails(), Tool::new("printf", &["from wl-paste"])], true);
301        assert_eq!(system.start(), ReadStep::Done(Some("from wl-paste".into())), "the first tool with text wins");
302        assert!(!system.is_reading());
303
304        let mut terminal = reader(vec![fails()], true);
305        assert_eq!(terminal.start(), ReadStep::AskTerminal, "no tool had text: ask the terminal");
306        assert!(terminal.is_reading());
307        assert_eq!(terminal.terminal_answer(Some("from OSC 52".into())), ReadStep::Done(Some("from OSC 52".into())));
308
309        let mut silent = reader(vec![fails()], true);
310        silent.start();
311        assert_eq!(silent.terminal_answer(None), ReadStep::Done(None), "then the application's own copy");
312        assert_eq!(silent.terminal_answer(Some("late".into())), ReadStep::Waiting, "a late answer is ignored");
313
314        let mut without_terminal = reader(Vec::new(), false);
315        assert_eq!(without_terminal.start(), ReadStep::Done(None));
316    }
317
318    #[test]
319    fn a_threaded_tool_is_polled_until_it_answers() {
320        let mut reader = ClipboardReader {
321            system: SystemClipboard::Tools(vec![Tool::new("printf", &["threaded"])]),
322            terminal: false,
323            threaded: true,
324            state: ReadState::Idle,
325        };
326        assert_eq!(reader.start(), ReadStep::Waiting);
327        let started = Instant::now();
328        let step = loop {
329            match reader.poll() {
330                ReadStep::Waiting if started.elapsed() < Duration::from_secs(5) => std::thread::yield_now(),
331                step => break step,
332            }
333        };
334        assert_eq!(step, ReadStep::Done(Some("threaded".into())));
335    }
336
337    #[derive(Default)]
338    struct Reader {
339        read: Vec<Option<String>>,
340        pasted: Vec<String>,
341    }
342
343    enum Msg {
344        Read,
345        Got(Option<String>),
346        Copy,
347        Pasted(String),
348    }
349
350    impl crate::runtime::App for Reader {
351        type Msg = Msg;
352        fn update(&mut self, msg: Msg) -> crate::runtime::Command<Msg> {
353            match msg {
354                Msg::Read => return crate::runtime::Command::read_clipboard(Msg::Got),
355                Msg::Got(text) => self.read.push(text),
356                Msg::Copy => return crate::runtime::Command::copy("inside the app"),
357                Msg::Pasted(text) => self.pasted.push(text),
358            }
359            crate::runtime::Command::none()
360        }
361        fn view(&self, _ui: &mut crate::widget::View<'_, Msg>) {}
362        fn clipboard(&self, event: &ClipboardEvent) -> Option<Msg> {
363            match event {
364                ClipboardEvent::Pasted(text) => Some(Msg::Pasted(text.clone())),
365                ClipboardEvent::Copied(_) => None,
366            }
367        }
368    }
369
370    #[test]
371    fn read_clipboard_and_the_paste_key_share_the_order_of_sources() {
372        let mut h = crate::runtime::Harness::new(Reader::default(), 20, 2);
373        h.send(Msg::Read).press("ctrl+v");
374        assert_eq!((h.app().read.clone(), h.app().pasted.len()), (vec![None], 0), "nothing anywhere");
375        h.send(Msg::Copy).send(Msg::Read).press("ctrl+v");
376        assert_eq!(h.app().read.last(), Some(&Some("inside the app".to_owned())), "the last copy inside");
377        assert_eq!(h.app().pasted, ["inside the app"]);
378        h.set_system_clipboard(Some("from another program")).send(Msg::Read).press("ctrl+v");
379        assert_eq!(h.app().read.last(), Some(&Some("from another program".to_owned())), "the system first");
380        assert_eq!(h.app().pasted.last().map(String::as_str), Some("from another program"));
381    }
382
383    #[test]
384    fn decodes_base64_answers() {
385        assert_eq!(decode_base64("ZGVwbG95LWFwaQ=="), Some("deploy-api".into()));
386        assert_eq!(decode_base64("w6dheQ=="), Some("çay".into()));
387        assert_eq!(decode_base64(""), Some(String::new()));
388        assert_eq!(decode_base64("//79"), None, "not UTF-8");
389    }
390}