1use std::io::Read;
13use std::process::{Command, Stdio};
14use std::sync::mpsc::{self, Receiver, TryRecvError};
15use std::time::{Duration, Instant};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ClipboardEvent {
21 Copied(String),
23 Pasted(String),
26}
27
28pub(crate) const TOOL_TIMEOUT: Duration = Duration::from_millis(500);
30
31const TOOL_POLL: Duration = Duration::from_millis(5);
33
34#[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 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 let mut stdout = child.stdout.take()?;
59 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();
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 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
92pub(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#[derive(Debug, Clone, PartialEq, Eq)]
111pub(crate) enum SystemClipboard {
112 Tools(Vec<Tool>),
114 Fixed(Option<String>),
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub(crate) enum ReadStep {
121 Waiting,
123 AskTerminal,
126 Done(Option<String>),
129}
130
131pub(crate) struct ClipboardReader {
133 system: SystemClipboard,
134 terminal: bool,
135 threaded: bool,
137 state: ReadState,
138}
139
140enum ReadState {
141 Idle,
142 System(Receiver<Option<String>>),
143 Terminal,
144}
145
146impl ClipboardReader {
147 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 pub(crate) fn fixed() -> Self {
155 Self { system: SystemClipboard::Fixed(None), terminal: false, threaded: false, state: ReadState::Idle }
156 }
157
158 pub(crate) fn set_system(&mut self, system: SystemClipboard) {
160 self.system = system;
161 }
162
163 pub(crate) fn is_reading(&self) -> bool {
165 !matches!(self.state, ReadState::Idle)
166 }
167
168 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 let _ = sender.send(tools.iter().find_map(|tool| tool.read(TOOL_TIMEOUT)));
180 });
181 if spawned.is_err() {
182 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 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 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
231pub(crate) const OSC52_QUERY: &str = "\x1b]52;c;?\x07";
233
234pub(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}