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();
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
85pub(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#[derive(Debug, Clone, PartialEq, Eq)]
104pub(crate) enum SystemClipboard {
105 Tools(Vec<Tool>),
107 Fixed(Option<String>),
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub(crate) enum ReadStep {
114 Waiting,
116 AskTerminal,
119 Done(Option<String>),
122}
123
124pub(crate) struct ClipboardReader {
126 system: SystemClipboard,
127 terminal: bool,
128 threaded: bool,
130 state: ReadState,
131}
132
133enum ReadState {
134 Idle,
135 System(Receiver<Option<String>>),
136 Terminal,
137}
138
139impl ClipboardReader {
140 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 pub(crate) fn fixed() -> Self {
148 Self { system: SystemClipboard::Fixed(None), terminal: false, threaded: false, state: ReadState::Idle }
149 }
150
151 pub(crate) fn set_system(&mut self, system: SystemClipboard) {
153 self.system = system;
154 }
155
156 pub(crate) fn is_reading(&self) -> bool {
158 !matches!(self.state, ReadState::Idle)
159 }
160
161 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 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 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 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
221pub(crate) const OSC52_QUERY: &str = "\x1b]52;c;?\x07";
223
224pub(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}