1use std::io::{self, Read, Write};
5use std::net::Shutdown;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{mpsc, Arc};
9use std::thread::{self, JoinHandle};
10use std::time::{Duration, Instant};
11
12pub const MAX_TEXT_BYTES: usize = 64 * 1024;
14pub const CHUNK_BYTES: usize = 384;
16const DEADLINE: Duration = Duration::from_secs(5);
17
18#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum Command {
21 Status,
23 Read(u64),
25 Chunk(u64, u64, usize),
27 Begin(u64, usize),
29 Append(u64, u64, usize, Vec<u8>),
31 Commit(u64, u64),
33 Cancel(u64, u64),
35 Clear(u64),
37}
38impl Command {
39 pub fn scope(&self) -> u64 {
41 match self {
42 Self::Status => 0,
43 Self::Read(s)
44 | Self::Clear(s)
45 | Self::Chunk(s, ..)
46 | Self::Begin(s, ..)
47 | Self::Append(s, ..)
48 | Self::Commit(s, ..)
49 | Self::Cancel(s, ..) => *s,
50 }
51 }
52}
53fn number(text: &str) -> Result<u64, &'static str> {
54 if text.is_empty()
55 || text.len() > 20
56 || !text.bytes().all(|b| b.is_ascii_digit())
57 || (text.len() > 1 && text.starts_with('0'))
58 {
59 return Err("invalid clipboard number");
60 }
61 text.parse().map_err(|_| "invalid clipboard number")
62}
63fn offset(text: &str) -> Result<usize, &'static str> {
64 let n = number(text)?;
65 if n > MAX_TEXT_BYTES as u64 {
66 return Err("clipboard size exceeds limit");
67 }
68 Ok(n as usize)
69}
70pub fn parse(text: &str) -> Result<Command, &'static str> {
72 if text.len() > 1024 || text.bytes().any(|b| b.is_ascii_control()) {
73 return Err("invalid clipboard command");
74 }
75 let words: Vec<_> = text.split(' ').collect();
76 match words.as_slice() {
77 ["clipboard", "native", "status", "--json"] => Ok(Command::Status),
78 ["clipboard", "read", s] => Ok(Command::Read(number(s)?)),
79 ["clipboard", "chunk", s, g, n] => Ok(Command::Chunk(number(s)?, number(g)?, offset(n)?)),
80 ["clipboard", "begin", s, n] => Ok(Command::Begin(number(s)?, offset(n)?)),
81 ["clipboard", "append", s, t, n, bytes] => Ok(Command::Append(
82 number(s)?,
83 number(t)?,
84 offset(n)?,
85 unhex(bytes)?,
86 )),
87 ["clipboard", "commit", s, t] => Ok(Command::Commit(number(s)?, number(t)?)),
88 ["clipboard", "cancel", s, t] => Ok(Command::Cancel(number(s)?, number(t)?)),
89 ["clipboard", "clear", s] => Ok(Command::Clear(number(s)?)),
90 _ => Err("invalid clipboard command"),
91 }
92}
93fn hex(bytes: &[u8]) -> String {
94 const DIGITS: &[u8] = b"0123456789abcdef";
95 let mut result = String::with_capacity(bytes.len() * 2);
96 for b in bytes {
97 result.push(DIGITS[(b >> 4) as usize] as char);
98 result.push(DIGITS[(b & 15) as usize] as char);
99 }
100 result
101}
102fn unhex(text: &str) -> Result<Vec<u8>, &'static str> {
103 if text.is_empty() || text.len() > CHUNK_BYTES * 2 || text.len() & 1 != 0 {
104 return Err("invalid clipboard chunk");
105 }
106 let digit = |b| match b {
107 b'0'..=b'9' => Ok(b - b'0'),
108 b'a'..=b'f' => Ok(b - b'a' + 10),
109 _ => Err("invalid clipboard chunk"),
110 };
111 text.as_bytes()
112 .chunks_exact(2)
113 .map(|c| Ok(digit(c[0])? * 16 + digit(c[1])?))
114 .collect()
115}
116
117pub struct Client {
119 socket: PathBuf,
120 scope: u64,
121 deadline: Instant,
122 stop: Arc<AtomicBool>,
123}
124impl Client {
125 pub fn new(socket: &Path, scope: u64) -> Self {
127 Self {
128 socket: socket.into(),
129 scope,
130 deadline: Instant::now() + DEADLINE,
131 stop: Arc::new(AtomicBool::new(false)),
132 }
133 }
134 fn check(&self) -> io::Result<Duration> {
135 if self.stop.load(Ordering::Relaxed) {
136 return Err(io::ErrorKind::Interrupted.into());
137 }
138 let remaining = self.deadline.saturating_duration_since(Instant::now());
139 if remaining.is_zero() {
140 return Err(io::ErrorKind::TimedOut.into());
141 }
142 Ok(remaining.min(Duration::from_millis(250)))
143 }
144 fn query(&self, text: &str) -> io::Result<String> {
145 self.check()?;
146 let mut socket = crate::platform::connect_local_until(&self.socket, self.deadline)?;
147 socket.set_nonblocking(false)?;
148 socket.set_write_timeout(Some(self.check()?))?;
149 socket.write_all(text.as_bytes())?;
150 socket.set_write_timeout(Some(self.check()?))?;
151 socket.write_all(b"\n")?;
152 socket.shutdown(Shutdown::Write)?;
153 let mut result = Vec::new();
154 let mut buffer = [0; 256];
155 loop {
156 socket.set_read_timeout(Some(self.check()?))?;
157 let n = socket.read(&mut buffer)?;
158 if n == 0 {
159 break;
160 }
161 result.extend_from_slice(&buffer[..n]);
162 if result.len() > 1024 {
163 return Err(io::Error::other("native clipboard response exceeds limit"));
164 }
165 }
166 let result = String::from_utf8(result)
167 .map_err(|_| io::Error::other("invalid native clipboard response"))?;
168 let result = result.strip_suffix('\n').unwrap_or(&result);
169 if !result.starts_with("ok") || result.contains(['\r', '\n', '\0']) {
170 return Err(io::Error::other(
171 "native clipboard request unavailable or denied",
172 ));
173 }
174 self.check()?;
175 Ok(result.into())
176 }
177 pub fn read(&self) -> io::Result<String> {
179 let response = self.query(&format!("clipboard read {}", self.scope))?;
180 let (generation, len) = summary(&response)?;
181 let mut bytes = Vec::with_capacity(len);
182 while bytes.len() < len {
183 let result = self.query(&format!(
184 "clipboard chunk {} {} {}",
185 self.scope,
186 generation,
187 bytes.len()
188 ))?;
189 let chunk = result
190 .strip_prefix("ok chunk ")
191 .ok_or_else(|| io::Error::other("invalid clipboard chunk response"))?;
192 let chunk = unhex(chunk).map_err(io::Error::other)?;
193 if chunk.len() != CHUNK_BYTES.min(len - bytes.len()) {
194 return Err(io::Error::other("invalid clipboard chunk length"));
195 }
196 bytes.extend_from_slice(&chunk);
197 }
198 String::from_utf8(bytes).map_err(|_| io::Error::other("invalid clipboard UTF-8"))
199 }
200 pub fn write(&self, text: &str) -> io::Result<()> {
202 if text.len() > MAX_TEXT_BYTES {
203 return Err(io::Error::other("clipboard text exceeds limit"));
204 }
205 let response = self.query(&format!("clipboard begin {} {}", self.scope, text.len()))?;
206 let txn = response
207 .strip_prefix("ok write ")
208 .and_then(|s| number(s).ok())
209 .filter(|n| *n > 0)
210 .ok_or_else(|| io::Error::other("invalid clipboard transaction response"))?;
211 let result = (|| {
212 for (i, chunk) in text.as_bytes().chunks(CHUNK_BYTES).enumerate() {
213 if self.query(&format!(
214 "clipboard append {} {} {} {}",
215 self.scope,
216 txn,
217 i * CHUNK_BYTES,
218 hex(chunk)
219 ))? != "ok"
220 {
221 return Err(io::Error::other("invalid clipboard append response"));
222 }
223 }
224 let response = self.query(&format!("clipboard commit {} {}", self.scope, txn))?;
225 let (_, bytes) = summary(&response)?;
226 if bytes != text.len() {
227 return Err(io::Error::other("invalid clipboard commit response"));
228 }
229 Ok(())
230 })();
231 if result.is_err() {
232 let _ = self.query(&format!("clipboard cancel {} {}", self.scope, txn));
233 }
234 result
235 }
236 pub fn clear(&self) -> io::Result<()> {
238 summary(&self.query(&format!("clipboard clear {}", self.scope))?).map(|_| ())
239 }
240}
241fn summary(text: &str) -> io::Result<(u64, usize)> {
242 let words: Vec<_> = text.split(' ').collect();
243 match words.as_slice() {
244 ["ok", "clipboard", generation, len] => Ok((
245 number(generation).map_err(io::Error::other)?,
246 offset(len).map_err(io::Error::other)?,
247 )),
248 _ => Err(io::Error::other("invalid clipboard summary")),
249 }
250}
251pub fn socket_for_window(path: &Path) -> io::Result<PathBuf> {
253 use std::os::unix::ffi::OsStrExt;
254 let name = path
255 .file_name()
256 .ok_or_else(|| io::Error::other("missing native endpoint name"))?
257 .as_bytes();
258 let name = name
259 .strip_suffix(b".windows")
260 .filter(|n| !n.is_empty())
261 .ok_or_else(|| {
262 io::Error::other("native clipboard requires a matching compositor control endpoint")
263 })?;
264 Ok(path.with_file_name(std::ffi::OsStr::from_bytes(name)))
265}
266struct Job {
267 id: u64,
268 scope: u64,
269 text: Option<String>,
270}
271pub struct Outcome {
273 pub id: u64,
275 pub value: io::Result<Option<String>>,
277}
278pub struct Worker {
280 sender: Option<mpsc::SyncSender<Job>>,
281 receiver: mpsc::Receiver<Outcome>,
282 stop: Arc<AtomicBool>,
283 thread: Option<JoinHandle<()>>,
284 next: u64,
285 busy: bool,
286}
287impl Worker {
288 pub fn new(socket: PathBuf) -> io::Result<Self> {
290 let (sender, jobs) = mpsc::sync_channel::<Job>(1);
291 let (results, receiver) = mpsc::sync_channel(1);
292 let stop = Arc::new(AtomicBool::new(false));
293 let cancel = Arc::clone(&stop);
294 let thread = thread::Builder::new()
295 .name("aen-clipboard".into())
296 .spawn(move || {
297 while let Ok(job) = jobs.recv() {
298 if cancel.load(Ordering::Relaxed) {
299 break;
300 }
301 let mut client = Client::new(&socket, job.scope);
302 client.stop = Arc::clone(&cancel);
303 let value = match job.text {
304 Some(text) => client.write(&text).map(|_| None),
305 None => client.read().map(Some),
306 };
307 if results.try_send(Outcome { id: job.id, value }).is_err() {
308 break;
309 }
310 }
311 })?;
312 Ok(Self {
313 sender: Some(sender),
314 receiver,
315 stop,
316 thread: Some(thread),
317 next: 0,
318 busy: false,
319 })
320 }
321 pub fn request(&mut self, scope: u64, text: Option<String>) -> io::Result<u64> {
323 if self.busy {
324 return Err(io::ErrorKind::WouldBlock.into());
325 }
326 if text.as_ref().is_some_and(|s| s.len() > MAX_TEXT_BYTES) {
327 return Err(io::Error::other("clipboard text exceeds limit"));
328 }
329 let id = self
330 .next
331 .checked_add(1)
332 .ok_or_else(|| io::Error::other("clipboard serial exhausted"))?;
333 self.sender
334 .as_ref()
335 .ok_or(io::ErrorKind::BrokenPipe)?
336 .try_send(Job { id, scope, text })
337 .map_err(|_| {
338 io::Error::new(io::ErrorKind::WouldBlock, "native clipboard worker is busy")
339 })?;
340 self.next = id;
341 self.busy = true;
342 Ok(id)
343 }
344 pub fn poll(&mut self) -> Option<Outcome> {
346 match self.receiver.try_recv() {
347 Ok(result) => {
348 self.busy = false;
349 Some(result)
350 }
351 _ => None,
352 }
353 }
354}
355impl Drop for Worker {
356 fn drop(&mut self) {
357 self.stop.store(true, Ordering::Relaxed);
358 self.sender.take();
359 if let Some(thread) = self.thread.take() {
360 let _ = thread.join();
361 }
362 }
363}
364
365#[cfg(feature = "server")]
366mod store {
367 use super::*;
368 struct Pending {
369 scope: u64,
370 id: u64,
371 total: usize,
372 bytes: Vec<u8>,
373 deadline: Instant,
374 }
375 pub struct Store {
377 text: String,
378 generation: u64,
379 next: u64,
380 pending: Option<Pending>,
381 }
382 impl Default for Store {
383 fn default() -> Self {
384 Self {
385 text: String::new(),
386 generation: 1,
387 next: 0,
388 pending: None,
389 }
390 }
391 }
392 impl Store {
393 pub fn cancel_scope(&mut self, scope: u64) {
395 if self.pending.as_ref().is_some_and(|p| p.scope == scope) {
396 self.pending = None;
397 }
398 }
399 pub fn handle(&mut self, command: &Command) -> String {
401 self.handle_at(command, Instant::now())
402 .unwrap_or_else(|error| format!("error: {error}"))
403 }
404 fn handle_at(&mut self, command: &Command, now: Instant) -> Result<String, &'static str> {
405 if self.pending.as_ref().is_some_and(|p| now >= p.deadline) {
406 self.pending = None;
407 }
408 let summary =
409 |generation: u64, bytes: usize| format!("ok clipboard {generation} {bytes}");
410 match command {
411 Command::Status => Ok(format!(
412 "{{\"transport\":\"aen\",\"mime_type\":\"text/plain;charset=utf-8\",\"generation\":{},\"bytes\":{},\"pending_bytes\":{},\"max_bytes\":{MAX_TEXT_BYTES}}}",
413 self.generation, self.text.len(), self.pending.as_ref().map_or(0, |p| p.bytes.len())
414 )),
415 Command::Read(_) => Ok(summary(self.generation, self.text.len())),
416 Command::Chunk(_, generation, offset) => {
417 if *generation != self.generation || *offset >= self.text.len() {
418 return Err("clipboard generation or offset is stale");
419 }
420 let end = self.text.len().min(*offset + CHUNK_BYTES);
421 Ok(format!("ok chunk {}", hex(&self.text.as_bytes()[*offset..end])))
422 }
423 Command::Begin(scope, total) => {
424 if *total > MAX_TEXT_BYTES {
425 return Err("clipboard size exceeds limit");
426 }
427 if self.pending.is_some() {
428 return Err("clipboard writer is busy");
429 }
430 self.next = self.next.checked_add(1).ok_or("clipboard serial exhausted")?;
431 self.pending = Some(Pending {
432 scope: *scope, id: self.next, total: *total,
433 bytes: Vec::with_capacity(*total), deadline: now + DEADLINE,
434 });
435 Ok(format!("ok write {}", self.next))
436 }
437 Command::Append(scope, id, offset, bytes) => {
438 let p = self.pending.as_mut()
439 .filter(|p| p.scope == *scope && p.id == *id)
440 .ok_or("clipboard transaction is stale")?;
441 if *offset != p.bytes.len() || bytes.is_empty()
442 || bytes.len() > CHUNK_BYTES || bytes.len() > p.total.saturating_sub(p.bytes.len()) {
443 return Err("clipboard chunk is invalid");
444 }
445 p.bytes.extend_from_slice(bytes);
446 Ok("ok".into())
447 }
448 Command::Commit(scope, id) => {
449 let p = self.pending.as_ref()
450 .filter(|p| p.scope == *scope && p.id == *id)
451 .ok_or("clipboard transaction is stale")?;
452 if p.bytes.len() != p.total || std::str::from_utf8(&p.bytes).is_err() {
453 return Err("clipboard write is incomplete or not UTF-8");
454 }
455 let generation = self.generation.checked_add(1).ok_or("clipboard generation exhausted")?;
456 let p = self.pending.take().ok_or("clipboard transaction is stale")?;
457 self.text = String::from_utf8(p.bytes).map_err(|_| "invalid clipboard UTF-8")?;
458 self.generation = generation;
459 Ok(summary(generation, self.text.len()))
460 }
461 Command::Cancel(scope, id) => {
462 if !self.pending.as_ref().is_some_and(|p| p.scope == *scope && p.id == *id) {
463 return Err("clipboard transaction is stale");
464 }
465 self.pending = None;
466 Ok("ok".into())
467 }
468 Command::Clear(_) => {
469 self.generation = self.generation.checked_add(1).ok_or("clipboard generation exhausted")?;
470 self.text.clear();
471 self.pending = None;
472 Ok(summary(self.generation, 0))
473 }
474 }
475 }
476 }
477 #[cfg(test)]
478 mod tests {
479 use super::*;
480 #[test]
481 fn incomplete_invalid_stale_and_expired_writes_preserve_selection() {
482 let mut s = Store::default();
483 let now = Instant::now();
484 assert_eq!(
485 s.handle_at(&Command::Begin(7, 2), now).unwrap(),
486 "ok write 1"
487 );
488 assert!(s
489 .handle_at(&Command::Append(7, 1, 1, vec![1]), now)
490 .is_err());
491 assert!(s.handle_at(&Command::Commit(7, 1), now).is_err());
492 assert_eq!(
493 s.handle_at(&Command::Read(0), now).unwrap(),
494 "ok clipboard 1 0"
495 );
496 assert!(s.handle_at(&Command::Commit(7, 1), now + DEADLINE).is_err());
497 s.handle_at(&Command::Begin(7, 1), now).unwrap();
498 s.handle_at(&Command::Append(7, 2, 0, vec![0xff]), now)
499 .unwrap();
500 assert!(s.handle_at(&Command::Commit(7, 2), now).is_err());
501 s.cancel_scope(7);
502 assert!(s.pending.is_none());
503 }
504 #[test]
505 fn replacement_is_atomic_and_generations_prevent_mixed_reads() {
506 let mut s = Store::default();
507 let now = Instant::now();
508 s.handle_at(&Command::Begin(9, 3), now).unwrap();
509 s.handle_at(&Command::Append(9, 1, 0, b"old".to_vec()), now)
510 .unwrap();
511 assert_eq!(
512 s.handle_at(&Command::Commit(9, 1), now).unwrap(),
513 "ok clipboard 2 3"
514 );
515 s.handle_at(&Command::Begin(9, 3), now).unwrap();
516 s.handle_at(&Command::Append(9, 2, 0, b"new".to_vec()), now)
517 .unwrap();
518 assert_eq!(
519 s.handle_at(&Command::Chunk(0, 2, 0), now).unwrap(),
520 "ok chunk 6f6c64"
521 );
522 assert!(s.handle_at(&Command::Commit(8, 2), now).is_err());
523 s.handle_at(&Command::Commit(9, 2), now).unwrap();
524 assert!(s.handle_at(&Command::Chunk(0, 2, 0), now).is_err());
525 assert!(!s.handle(&Command::Status).contains("new"));
526 assert!(s
527 .handle_at(&Command::Begin(0, MAX_TEXT_BYTES + 1), now)
528 .is_err());
529 }
530 }
531}
532#[cfg(feature = "server")]
533pub use store::Store;
534#[cfg(test)]
535mod tests {
536 use super::*;
537 #[test]
538 fn strict_parser_and_chunks_cannot_expand_the_control_limit() {
539 for bad in [
540 "clipboard read -1",
541 "clipboard read 01",
542 "clipboard read 1 extra",
543 "clipboard read 1",
544 "clipboard begin 0 65537",
545 "clipboard append 0 1 0 0",
546 "clipboard append 0 1 0 GG",
547 "clipboard read 1\nshutdown",
548 ] {
549 assert!(parse(bad).is_err(), "{bad}");
550 }
551 let text = format!(
552 "clipboard append {} {} {} {}",
553 u64::MAX,
554 u64::MAX,
555 MAX_TEXT_BYTES,
556 hex(&vec![255; CHUNK_BYTES])
557 );
558 assert!(text.len() < 1024);
559 assert!(parse(&text).is_ok());
560 assert_eq!(unhex(&hex("é🙂".as_bytes())).unwrap(), "é🙂".as_bytes());
561 }
562 #[test]
563 fn expired_operations_never_connect_and_cancellation_is_observed() {
564 let path = Path::new("/tmp/aen-clipboard-never-connected");
565 let mut client = Client::new(path, 0);
566 client.deadline = Instant::now();
567 assert_eq!(client.read().unwrap_err().kind(), io::ErrorKind::TimedOut);
568 assert_eq!(
569 crate::platform::connect_local_until(path, client.deadline)
570 .unwrap_err()
571 .kind(),
572 io::ErrorKind::TimedOut
573 );
574 client.deadline = Instant::now() + DEADLINE;
575 client.stop.store(true, Ordering::Relaxed);
576 assert_eq!(
577 client.read().unwrap_err().kind(),
578 io::ErrorKind::Interrupted
579 );
580 }
581 #[test]
582 fn selected_window_socket_determines_control_endpoint() {
583 assert_eq!(
584 socket_for_window(Path::new("/tmp/private.shell.windows")).unwrap(),
585 Path::new("/tmp/private.shell")
586 );
587 assert!(socket_for_window(Path::new("/tmp/other")).is_err());
588 }
589}