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            let _ = child.kill();
65            let _ = child.wait();
66            return None;
67        };
68        let started = Instant::now();
69        let status = loop {
70            match child.try_wait() {
71                Ok(Some(status)) => break status,
72                Ok(None) if started.elapsed() < timeout => std::thread::sleep(TOOL_POLL),
73                _ => {
74                    let _ = child.kill();
75                    let _ = child.wait();
76                    return None;
77                }
78            }
79        };
80        let text = String::from_utf8(output.join().ok()?.ok()?).ok()?;
81        (status.success() && !text.is_empty()).then_some(text)
82    }
83}
84
85/// The clipboard tools to try on this system, in order. `var` tells whether an environment
86/// variable is set.
87pub(crate) fn platform_tools(var: impl Fn(&str) -> bool) -> Vec<Tool> {
88    if cfg!(target_os = "macos") {
89        return vec![Tool::new("pbpaste", &[])];
90    }
91    let mut tools = Vec::new();
92    if var("WAYLAND_DISPLAY") {
93        tools.push(Tool::new("wl-paste", &["--no-newline", "--type", "text"]));
94    }
95    if var("DISPLAY") {
96        tools.push(Tool::new("xclip", &["-o", "-selection", "clipboard"]));
97        tools.push(Tool::new("xsel", &["--clipboard", "--output"]));
98    }
99    tools
100}
101
102/// Where the first source, the system clipboard, comes from.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub(crate) enum SystemClipboard {
105    /// The platform's clipboard programs, tried in order.
106    Tools(Vec<Tool>),
107    /// A fixed answer: the test harness never touches the real clipboard.
108    Fixed(Option<String>),
109}
110
111/// What reading the clipboard needs next.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub(crate) enum ReadStep {
114    /// The system tool is still running on its thread.
115    Waiting,
116    /// The system clipboard gave nothing: ask the terminal with OSC 52 and pass its answer to
117    /// [`ClipboardReader::terminal_answer`].
118    AskTerminal,
119    /// Reading ended; `None` when neither the system nor the terminal had text, so the text the
120    /// application copied last is used.
121    Done(Option<String>),
122}
123
124/// Reads the clipboard from the system tool, then the terminal; see the module docs.
125pub(crate) struct ClipboardReader {
126    system: SystemClipboard,
127    terminal: bool,
128    /// Whether the tools run on a thread (a runtime) or inline.
129    threaded: bool,
130    state: ReadState,
131}
132
133enum ReadState {
134    Idle,
135    System(Receiver<Option<String>>),
136    Terminal,
137}
138
139impl ClipboardReader {
140    /// A reader for a runtime: the platform's tools on a thread, then the terminal.
141    pub(crate) fn runtime() -> Self {
142        let tools = platform_tools(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()));
143        Self { system: SystemClipboard::Tools(tools), terminal: true, threaded: true, state: ReadState::Idle }
144    }
145
146    /// A reader for tests: a fixed system clipboard that starts empty and no terminal.
147    pub(crate) fn fixed() -> Self {
148        Self { system: SystemClipboard::Fixed(None), terminal: false, threaded: false, state: ReadState::Idle }
149    }
150
151    /// Replaces the system clipboard source.
152    pub(crate) fn set_system(&mut self, system: SystemClipboard) {
153        self.system = system;
154    }
155
156    /// Whether a read is under way.
157    pub(crate) fn is_reading(&self) -> bool {
158        !matches!(self.state, ReadState::Idle)
159    }
160
161    /// Starts reading. Call only while no read is under way.
162    pub(crate) fn start(&mut self) -> ReadStep {
163        match &self.system {
164            SystemClipboard::Fixed(text) => self.after_system(text.clone()),
165            SystemClipboard::Tools(tools) if self.threaded => {
166                let tools = tools.clone();
167                let (sender, receiver) = mpsc::channel();
168                let spawned = std::thread::Builder::new().name("quvyta-clipboard".to_owned()).spawn(move || {
169                    let _ = sender.send(tools.iter().find_map(|tool| tool.read(TOOL_TIMEOUT)));
170                });
171                if spawned.is_err() {
172                    // No thread to wait on: skip the system tools and go on to the next source.
173                    return self.after_system(None);
174                }
175                self.state = ReadState::System(receiver);
176                ReadStep::Waiting
177            }
178            SystemClipboard::Tools(tools) => {
179                let text = tools.iter().find_map(|tool| tool.read(TOOL_TIMEOUT));
180                self.after_system(text)
181            }
182        }
183    }
184
185    /// Checks on a system tool running on its thread.
186    pub(crate) fn poll(&mut self) -> ReadStep {
187        let ReadState::System(receiver) = &self.state else {
188            return ReadStep::Waiting;
189        };
190        match receiver.try_recv() {
191            Ok(text) => self.after_system(text),
192            Err(TryRecvError::Empty) => ReadStep::Waiting,
193            Err(TryRecvError::Disconnected) => self.after_system(None),
194        }
195    }
196
197    /// The terminal's answer to the OSC 52 query, `None` when it did not answer in time.
198    pub(crate) fn terminal_answer(&mut self, text: Option<String>) -> ReadStep {
199        if !matches!(self.state, ReadState::Terminal) {
200            return ReadStep::Waiting;
201        }
202        self.state = ReadState::Idle;
203        ReadStep::Done(text.filter(|text| !text.is_empty()))
204    }
205
206    fn after_system(&mut self, text: Option<String>) -> ReadStep {
207        if text.is_some() {
208            self.state = ReadState::Idle;
209            return ReadStep::Done(text);
210        }
211        if self.terminal {
212            self.state = ReadState::Terminal;
213            ReadStep::AskTerminal
214        } else {
215            self.state = ReadState::Idle;
216            ReadStep::Done(None)
217        }
218    }
219}
220
221/// The OSC 52 query asking the terminal for its clipboard.
222pub(crate) const OSC52_QUERY: &str = "\x1b]52;c;?\x07";
223
224/// Decodes standard base64, ignoring anything outside the alphabet such as line breaks. Returns
225/// `None` for text that is not valid UTF-8 once decoded.
226pub(crate) fn decode_base64(encoded: &str) -> Option<String> {
227    let value = |c: u8| match c {
228        b'A'..=b'Z' => Some(c - b'A'),
229        b'a'..=b'z' => Some(c - b'a' + 26),
230        b'0'..=b'9' => Some(c - b'0' + 52),
231        b'+' => Some(62),
232        b'/' => Some(63),
233        _ => None,
234    };
235    let mut bytes = Vec::new();
236    let mut buffer = 0u32;
237    let mut bits = 0;
238    for sextet in encoded.bytes().take_while(|c| *c != b'=').filter_map(value) {
239        buffer = (buffer << 6) | u32::from(sextet);
240        bits += 6;
241        if bits >= 8 {
242            bits -= 8;
243            bytes.push(u8::try_from((buffer >> bits) & 0xff).unwrap_or(0));
244        }
245    }
246    String::from_utf8(bytes).ok()
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn fails() -> Tool {
254        Tool::new("quvyta-no-such-clipboard-tool", &[])
255    }
256
257    #[test]
258    fn tools_run_without_a_shell_and_fail_quietly() {
259        assert_eq!(Tool::new("printf", &["%s", "deploy $HOME"]).read(TOOL_TIMEOUT), Some("deploy $HOME".to_owned()));
260        assert_eq!(fails().read(TOOL_TIMEOUT), None, "a missing program");
261        assert_eq!(Tool::new("false", &[]).read(TOOL_TIMEOUT), None, "a failing program");
262        assert_eq!(Tool::new("printf", &[""]).read(TOOL_TIMEOUT), None, "an empty clipboard");
263        let started = Instant::now();
264        assert_eq!(Tool::new("sleep", &["5"]).read(Duration::from_millis(50)), None, "too slow");
265        assert!(started.elapsed() < Duration::from_secs(2), "the slow tool was stopped");
266    }
267
268    #[test]
269    fn platform_tools_follow_the_display_server() {
270        if cfg!(target_os = "macos") {
271            return;
272        }
273        let names = |vars: &[&str]| {
274            platform_tools(|name| vars.contains(&name)).into_iter().map(|tool| tool.program).collect::<Vec<_>>()
275        };
276        assert_eq!(names(&["WAYLAND_DISPLAY"]), ["wl-paste"]);
277        assert_eq!(names(&["DISPLAY"]), ["xclip", "xsel"]);
278        assert_eq!(names(&["WAYLAND_DISPLAY", "DISPLAY"]), ["wl-paste", "xclip", "xsel"]);
279        assert!(names(&[]).is_empty(), "over SSH without a display there is no tool");
280    }
281
282    #[test]
283    fn sources_are_tried_in_order() {
284        let reader = |tools: Vec<Tool>, terminal: bool| ClipboardReader {
285            system: SystemClipboard::Tools(tools),
286            terminal,
287            threaded: false,
288            state: ReadState::Idle,
289        };
290        let mut system = reader(vec![fails(), Tool::new("printf", &["from wl-paste"])], true);
291        assert_eq!(system.start(), ReadStep::Done(Some("from wl-paste".into())), "the first tool with text wins");
292        assert!(!system.is_reading());
293
294        let mut terminal = reader(vec![fails()], true);
295        assert_eq!(terminal.start(), ReadStep::AskTerminal, "no tool had text: ask the terminal");
296        assert!(terminal.is_reading());
297        assert_eq!(terminal.terminal_answer(Some("from OSC 52".into())), ReadStep::Done(Some("from OSC 52".into())));
298
299        let mut silent = reader(vec![fails()], true);
300        silent.start();
301        assert_eq!(silent.terminal_answer(None), ReadStep::Done(None), "then the application's own copy");
302        assert_eq!(silent.terminal_answer(Some("late".into())), ReadStep::Waiting, "a late answer is ignored");
303
304        let mut without_terminal = reader(Vec::new(), false);
305        assert_eq!(without_terminal.start(), ReadStep::Done(None));
306    }
307
308    #[test]
309    fn a_threaded_tool_is_polled_until_it_answers() {
310        let mut reader = ClipboardReader {
311            system: SystemClipboard::Tools(vec![Tool::new("printf", &["threaded"])]),
312            terminal: false,
313            threaded: true,
314            state: ReadState::Idle,
315        };
316        assert_eq!(reader.start(), ReadStep::Waiting);
317        let started = Instant::now();
318        let step = loop {
319            match reader.poll() {
320                ReadStep::Waiting if started.elapsed() < Duration::from_secs(5) => std::thread::yield_now(),
321                step => break step,
322            }
323        };
324        assert_eq!(step, ReadStep::Done(Some("threaded".into())));
325    }
326
327    #[derive(Default)]
328    struct Reader {
329        read: Vec<Option<String>>,
330        pasted: Vec<String>,
331    }
332
333    enum Msg {
334        Read,
335        Got(Option<String>),
336        Copy,
337        Pasted(String),
338    }
339
340    impl crate::runtime::App for Reader {
341        type Msg = Msg;
342        fn update(&mut self, msg: Msg) -> crate::runtime::Command<Msg> {
343            match msg {
344                Msg::Read => return crate::runtime::Command::read_clipboard(Msg::Got),
345                Msg::Got(text) => self.read.push(text),
346                Msg::Copy => return crate::runtime::Command::copy("inside the app"),
347                Msg::Pasted(text) => self.pasted.push(text),
348            }
349            crate::runtime::Command::none()
350        }
351        fn view(&self, _ui: &mut crate::widget::View<'_, Msg>) {}
352        fn clipboard(&self, event: &ClipboardEvent) -> Option<Msg> {
353            match event {
354                ClipboardEvent::Pasted(text) => Some(Msg::Pasted(text.clone())),
355                ClipboardEvent::Copied(_) => None,
356            }
357        }
358    }
359
360    #[test]
361    fn read_clipboard_and_the_paste_key_share_the_order_of_sources() {
362        let mut h = crate::runtime::Harness::new(Reader::default(), 20, 2);
363        h.send(Msg::Read).press("ctrl+v");
364        assert_eq!((h.app().read.clone(), h.app().pasted.len()), (vec![None], 0), "nothing anywhere");
365        h.send(Msg::Copy).send(Msg::Read).press("ctrl+v");
366        assert_eq!(h.app().read.last(), Some(&Some("inside the app".to_owned())), "the last copy inside");
367        assert_eq!(h.app().pasted, ["inside the app"]);
368        h.set_system_clipboard(Some("from another program")).send(Msg::Read).press("ctrl+v");
369        assert_eq!(h.app().read.last(), Some(&Some("from another program".to_owned())), "the system first");
370        assert_eq!(h.app().pasted.last().map(String::as_str), Some("from another program"));
371    }
372
373    #[test]
374    fn decodes_base64_answers() {
375        assert_eq!(decode_base64("ZGVwbG95LWFwaQ=="), Some("deploy-api".into()));
376        assert_eq!(decode_base64("w6dheQ=="), Some("çay".into()));
377        assert_eq!(decode_base64(""), Some(String::new()));
378        assert_eq!(decode_base64("//79"), None, "not UTF-8");
379    }
380}