1use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::time::{Duration, SystemTime, UNIX_EPOCH};
29
30use serde::Serialize;
31
32use crate::broker::PathBroker;
33use crate::error::ShellError;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Board {
37 General,
38 Named(String),
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
42pub struct ClipboardContent {
43 pub text: Option<String>,
44 pub html: Option<String>,
45 pub png: Option<Vec<u8>>,
46 pub files: Vec<String>,
48 pub custom: BTreeMap<String, Vec<u8>>,
50}
51
52pub const DEFAULT_PURGE_AFTER: Duration = Duration::from_secs(60 * 60);
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58#[serde(tag = "kind", rename_all = "lowercase")]
59pub enum ClipboardItem {
60 Files { paths: Vec<String> },
62 Image { path: String },
64 Text { text: String },
66 None,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum ImagePreference {
73 #[default]
75 PngFirst,
76 DibFirst,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub struct ClipboardOptions {
84 pub image_preference: ImagePreference,
87 pub custom_max_bytes: Option<usize>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Default)]
96pub struct ClipboardImage {
97 pub extension: String,
98 pub bytes: Vec<u8>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Default)]
103pub struct ClipboardSnapshot {
104 pub files: Vec<PathBuf>,
105 pub image: Option<ClipboardImage>,
106 pub text: Option<String>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct ClipboardPolicy {
112 pub purge_after: Duration,
114}
115
116impl Default for ClipboardPolicy {
117 fn default() -> Self {
118 Self {
119 purge_after: DEFAULT_PURGE_AFTER,
120 }
121 }
122}
123
124pub fn clipboard_temp_dir(app_id: &str) -> PathBuf {
126 std::env::temp_dir().join(format!("{app_id}-clipboard"))
127}
128
129pub fn write(board: &Board, c: &ClipboardContent) -> Result<(), String> {
130 imp::write(board, c)
131}
132pub fn read(board: &Board) -> Result<ClipboardContent, String> {
134 imp::read(board, &ClipboardOptions::default())
135}
136pub fn read_with(board: &Board, options: &ClipboardOptions) -> Result<ClipboardContent, String> {
138 imp::read(board, options)
139}
140pub fn formats(board: &Board) -> Result<Vec<String>, String> {
142 imp::formats(board)
143}
144
145pub fn take_item(
147 broker: &PathBroker,
148 temp_dir: &Path,
149 policy: ClipboardPolicy,
150) -> Result<ClipboardItem, ShellError> {
151 take_item_with(broker, temp_dir, policy, &ClipboardOptions::default())
152}
153
154pub fn take_item_with(
157 broker: &PathBroker,
158 temp_dir: &Path,
159 policy: ClipboardPolicy,
160 options: &ClipboardOptions,
161) -> Result<ClipboardItem, ShellError> {
162 take_item_from_with(&Board::General, broker, temp_dir, policy, options)
163}
164
165pub fn take_item_from_with(
176 board: &Board,
177 broker: &PathBroker,
178 temp_dir: &Path,
179 policy: ClipboardPolicy,
180 options: &ClipboardOptions,
181) -> Result<ClipboardItem, ShellError> {
182 let snapshot = platform_snapshot(board, temp_dir, options)?;
183 item_from_snapshot(&snapshot, broker, temp_dir, policy)
184}
185
186#[cfg(any(target_os = "macos", test))]
189fn fallback_allowed(board: &Board) -> bool {
190 matches!(board, Board::General)
191}
192
193#[cfg(any(target_os = "macos", test))]
195fn needs_file_fallback(board: &Board, files_found: bool, types: &[String]) -> bool {
196 fallback_allowed(board) && !files_found && types.iter().any(|t| t == "public.file-url")
197}
198
199#[cfg(any(target_os = "macos", test))]
201fn needs_png_fallback(board: &Board, types: &[String]) -> bool {
202 fallback_allowed(board)
203 && types
204 .iter()
205 .any(|t| t == "public.tiff" || t == "public.image")
206}
207
208pub fn named_board_refusal(name: &str) -> Option<&'static str> {
213 if name.trim().is_empty() {
214 Some("a named board needs a non-empty name")
215 } else if name
216 .trim_start()
217 .to_ascii_lowercase()
218 .starts_with("apple cfpasteboard")
219 {
220 Some("`Apple CFPasteboard ...` names are system pasteboards, not private boards")
221 } else {
222 None
223 }
224}
225
226#[cfg(all(feature = "qa-probes", debug_assertions))]
228pub mod qa;
229
230pub fn item_from_snapshot(
233 snapshot: &ClipboardSnapshot,
234 broker: &PathBroker,
235 temp_dir: &Path,
236 policy: ClipboardPolicy,
237) -> Result<ClipboardItem, ShellError> {
238 if temp_dir.is_dir() {
239 purge_stale(temp_dir, policy.purge_after);
240 }
241 let granted: Vec<String> = snapshot
242 .files
243 .iter()
244 .filter_map(|p| broker.grant_file(p).ok())
245 .collect();
246 if !granted.is_empty() {
247 return Ok(ClipboardItem::Files { paths: granted });
248 }
249 if let Some(image) = snapshot.image.as_ref().filter(|i| !i.bytes.is_empty()) {
250 let path = write_temp(temp_dir, &image.extension, &image.bytes)?;
251 return match broker.grant_file(&path) {
252 Ok(display) => Ok(ClipboardItem::Image { path: display }),
253 Err(e) => {
254 let _ = std::fs::remove_file(&path);
255 Err(e)
256 }
257 };
258 }
259 match snapshot.text.as_deref() {
260 Some(text) if !text.is_empty() => Ok(ClipboardItem::Text {
261 text: text.to_owned(),
262 }),
263 _ => Ok(ClipboardItem::None),
264 }
265}
266
267pub fn purge_stale(dir: &Path, older_than: Duration) -> usize {
270 let Some(cutoff) = SystemTime::now().checked_sub(older_than) else {
271 return 0;
272 };
273 let Ok(entries) = std::fs::read_dir(dir) else {
274 return 0;
275 };
276 let mut removed = 0;
277 for entry in entries.filter_map(Result::ok) {
278 let Ok(meta) = entry.metadata() else {
279 continue;
280 };
281 if !meta.is_file() {
282 continue;
283 }
284 let Ok(modified) = meta.modified() else {
285 continue;
286 };
287 if modified < cutoff && std::fs::remove_file(entry.path()).is_ok() {
288 removed += 1;
289 }
290 }
291 removed
292}
293
294fn write_temp(dir: &Path, extension: &str, bytes: &[u8]) -> Result<PathBuf, ShellError> {
296 use std::io::Write;
297 std::fs::create_dir_all(dir)?;
298 let ext: String = extension
299 .chars()
300 .filter(|c| c.is_ascii_alphanumeric())
301 .collect();
302 for _ in 0..8 {
303 let path = dir.join(format!("clipboard-{}.{ext}", unique_stamp()));
304 match std::fs::OpenOptions::new()
305 .write(true)
306 .create_new(true)
307 .open(&path)
308 {
309 Ok(mut file) => {
310 file.write_all(bytes)?;
311 return Ok(path);
312 }
313 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
314 Err(e) => return Err(e.into()),
315 }
316 }
317 Err(ShellError::Other(
318 "could not create a clipboard temp file".into(),
319 ))
320}
321
322fn unique_stamp() -> String {
323 static COUNTER: AtomicU64 = AtomicU64::new(0);
324 let nanos = SystemTime::now()
325 .duration_since(UNIX_EPOCH)
326 .map(|d| d.as_nanos())
327 .unwrap_or(0);
328 format!("{nanos}-{}", COUNTER.fetch_add(1, Ordering::Relaxed))
329}
330
331pub fn dib_to_bmp(dib: &[u8]) -> Option<Vec<u8>> {
334 if dib.len() < 40 {
335 return None;
336 }
337 let header_size = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]) as usize;
338 if header_size < 40 || dib.len() < header_size {
339 return None;
340 }
341 let bit_count = u16::from_le_bytes([dib[14], dib[15]]) as usize;
342 let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]);
343 let clr_used = u32::from_le_bytes([dib[32], dib[33], dib[34], dib[35]]) as usize;
344 let mask_bytes = match (header_size, compression) {
347 (40, 3) => 12,
348 (40, 6) => 16,
349 _ => 0,
350 };
351 let palette_colors = if bit_count == 0 {
353 0
354 } else if bit_count <= 8 {
355 if clr_used != 0 {
356 clr_used
357 } else {
358 1usize << bit_count
359 }
360 } else {
361 clr_used
362 };
363 let off_bits = 14 + header_size + mask_bytes + palette_colors * 4;
364 if off_bits > 14 + dib.len() {
365 return None;
366 }
367 let file_size = 14 + dib.len();
368 let mut out = Vec::with_capacity(file_size);
369 out.extend_from_slice(b"BM");
370 out.extend_from_slice(&(file_size as u32).to_le_bytes());
371 out.extend_from_slice(&0u32.to_le_bytes());
372 out.extend_from_slice(&(off_bits as u32).to_le_bytes());
373 out.extend_from_slice(dib);
374 Some(out)
375}
376
377#[cfg(target_os = "macos")]
378fn platform_snapshot(
379 board: &Board,
380 temp_dir: &Path,
381 options: &ClipboardOptions,
382) -> Result<ClipboardSnapshot, ShellError> {
383 let raw = read_with(board, options)?;
384 let types = formats(board)?;
385 let mut files: Vec<PathBuf> = raw.files.iter().map(PathBuf::from).collect();
386 if needs_file_fallback(board, !files.is_empty(), &types) {
388 files = macos_osascript::file_paths(board)
389 .into_iter()
390 .map(PathBuf::from)
391 .collect();
392 }
393 let image = match raw.png {
394 Some(png) => Some(ClipboardImage {
395 extension: "png".into(),
396 bytes: png,
397 }),
398 None if needs_png_fallback(board, &types) => macos_osascript::png_bytes(board, temp_dir)
399 .map(|bytes| ClipboardImage {
400 extension: "png".into(),
401 bytes,
402 }),
403 None => None,
404 };
405 Ok(ClipboardSnapshot {
406 files,
407 image,
408 text: raw.text,
409 })
410}
411
412#[cfg(windows)]
413fn platform_snapshot(
414 board: &Board,
415 _temp_dir: &Path,
416 options: &ClipboardOptions,
417) -> Result<ClipboardSnapshot, ShellError> {
418 let raw = read_with(board, options)?;
420 let png = raw.png.map(|bytes| ClipboardImage {
421 extension: "png".into(),
422 bytes,
423 });
424 let bmp = || -> Result<Option<ClipboardImage>, String> {
425 Ok(imp::dib_bytes()?
426 .and_then(|dib| dib_to_bmp(&dib))
427 .map(|bytes| ClipboardImage {
428 extension: "bmp".into(),
429 bytes,
430 }))
431 };
432 let image = match options.image_preference {
433 ImagePreference::PngFirst => match png {
435 Some(png) => Some(png),
436 None => bmp()?,
437 },
438 ImagePreference::DibFirst => match bmp()? {
440 Some(bmp) => Some(bmp),
441 None => png,
442 },
443 };
444 Ok(ClipboardSnapshot {
445 files: raw.files.iter().map(PathBuf::from).collect(),
446 image,
447 text: raw.text,
448 })
449}
450
451#[cfg(not(any(target_os = "macos", windows)))]
452fn platform_snapshot(
453 board: &Board,
454 _temp_dir: &Path,
455 options: &ClipboardOptions,
456) -> Result<ClipboardSnapshot, ShellError> {
457 if matches!(board, Board::Named(_)) {
458 return Err(match read_with(board, options) {
460 Err(message) => ShellError::from(message),
461 Ok(_) => ShellError::from("named boards are macOS-only"),
462 });
463 }
464 Ok(ClipboardSnapshot::default())
465}
466
467#[cfg(target_os = "macos")]
468mod macos_osascript {
469 use std::path::Path;
472 use std::process::Command;
473 use std::sync::atomic::{AtomicU64, Ordering};
474 use std::time::{SystemTime, UNIX_EPOCH};
475
476 fn run(lines: &[&str]) -> Option<String> {
477 let mut cmd = Command::new("/usr/bin/osascript");
478 for line in lines {
479 cmd.arg("-e").arg(line);
480 }
481 let output = cmd.output().ok()?;
482 output
483 .status
484 .success()
485 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
486 }
487
488 pub fn file_paths(board: &super::Board) -> Vec<String> {
491 if !super::fallback_allowed(board) {
492 return Vec::new();
493 }
494 let out = run(&[
495 "use framework \"AppKit\"",
496 "use scripting additions",
497 "set pb to current application's NSPasteboard's generalPasteboard()",
498 "set urlClass to current application's NSURL",
499 "set urls to pb's readObjectsForClasses:{urlClass} options:(missing value)",
500 "if urls is missing value then return \"\"",
501 "set out to {}",
502 "repeat with u in urls",
503 "if (u's isFileURL()) as boolean then set end of out to (u's |path|()) as text",
504 "end repeat",
505 "set AppleScript's text item delimiters to linefeed",
506 "return out as text",
507 ])
508 .unwrap_or_default();
509 out.lines()
510 .map(str::trim)
511 .filter(|line| !line.is_empty())
512 .map(str::to_owned)
513 .collect()
514 }
515
516 pub fn png_bytes(board: &super::Board, dir: &Path) -> Option<Vec<u8>> {
519 if !super::fallback_allowed(board) {
520 return None;
521 }
522 std::fs::create_dir_all(dir).ok()?;
523 static COUNTER: AtomicU64 = AtomicU64::new(0);
524 let nanos = SystemTime::now()
525 .duration_since(UNIX_EPOCH)
526 .map(|d| d.as_nanos())
527 .unwrap_or(0);
528 let path = dir.join(format!(
529 "clipboard-{nanos}-{}-coerced.png",
530 COUNTER.fetch_add(1, Ordering::Relaxed)
531 ));
532 let quoted = format!(
533 "\"{}\"",
534 path.to_string_lossy()
535 .replace('\\', "\\\\")
536 .replace('"', "\\\"")
537 );
538 let set_out_path = format!("set outPath to POSIX file {quoted}");
539 let wrote = run(&[
540 "try",
541 "set imageData to (the clipboard as «class PNGf»)",
542 set_out_path.as_str(),
543 "set fileRef to open for access outPath with write permission",
544 "set eof fileRef to 0",
545 "write imageData to fileRef",
546 "close access fileRef",
547 "return \"ok\"",
548 "on error",
549 "try",
550 "close access outPath",
551 "end try",
552 "return \"\"",
553 "end try",
554 ])
555 .is_some_and(|s| s == "ok");
556 let bytes = if wrote && path.is_file() {
557 std::fs::read(&path).ok()
558 } else {
559 None
560 };
561 let _ = std::fs::remove_file(&path);
562 bytes.filter(|b| !b.is_empty())
563 }
564}
565
566#[cfg(target_os = "macos")]
567mod imp {
568 use super::*;
569 use objc2::rc::Retained;
570 use objc2::runtime::ProtocolObject;
571 use objc2_app_kit::{NSPasteboard, NSPasteboardItem, NSPasteboardWriting};
572 use objc2_foundation::{ns_string, NSArray, NSData, NSString, NSURL};
573
574 const T_TEXT: &str = "public.utf8-plain-text";
575 const T_HTML: &str = "public.html";
576 const T_PNG: &str = "public.png";
577 const T_FILE: &str = "public.file-url";
578 const TEXT_FALLBACKS: [&str; 5] = [
582 T_TEXT,
583 "NSStringPboardType",
584 "public.utf16-external-plain-text",
585 "public.plain-text",
586 "public.text",
587 ];
588
589 fn board(b: &Board) -> Result<Retained<NSPasteboard>, String> {
592 match b {
593 Board::General => Ok(NSPasteboard::generalPasteboard()),
594 Board::Named(n) => {
595 if let Some(why) = named_board_refusal(n) {
596 return Err(format!("named board {n:?} refused: {why}"));
597 }
598 Ok(NSPasteboard::pasteboardWithName(&NSString::from_str(n)))
599 }
600 }
601 }
602
603 #[cfg(test)]
605 pub fn release_named(b: &Board) {
606 if matches!(b, Board::Named(_)) {
607 if let Ok(pb) = board(b) {
608 unsafe {
611 let _: () = objc2::msg_send![&*pb, releaseGlobally];
612 }
613 }
614 }
615 }
616
617 pub fn write(b: &Board, c: &ClipboardContent) -> Result<(), String> {
618 let pb = board(b)?;
619 pb.clearContents();
620 let mut items: Vec<Retained<ProtocolObject<dyn NSPasteboardWriting>>> = Vec::new();
621 let has_primary =
622 c.text.is_some() || c.html.is_some() || c.png.is_some() || !c.custom.is_empty();
623 if has_primary {
624 let item = NSPasteboardItem::new();
625 if let Some(t) = &c.text {
626 item.setString_forType(&NSString::from_str(t), &NSString::from_str(T_TEXT));
627 }
628 if let Some(h) = &c.html {
629 item.setString_forType(&NSString::from_str(h), &NSString::from_str(T_HTML));
630 }
631 if let Some(p) = &c.png {
632 item.setData_forType(&NSData::with_bytes(p), &NSString::from_str(T_PNG));
633 }
634 for (k, v) in &c.custom {
635 item.setData_forType(&NSData::with_bytes(v), &NSString::from_str(k));
636 }
637 items.push(ProtocolObject::from_retained(item));
638 }
639 for f in &c.files {
640 let url = NSURL::fileURLWithPath(&NSString::from_str(f));
641 let abs = url
642 .absoluteString()
643 .ok_or_else(|| format!("no file URL for {f}"))?;
644 let item = NSPasteboardItem::new();
645 item.setString_forType(&abs, &NSString::from_str(T_FILE));
646 items.push(ProtocolObject::from_retained(item));
647 }
648 if items.is_empty() {
649 return Ok(());
650 }
651 let array = NSArray::from_retained_slice(&items);
652 if pb.writeObjects(&array) {
653 Ok(())
654 } else {
655 Err("NSPasteboard rejected the content".into())
656 }
657 }
658
659 pub fn read(b: &Board, options: &ClipboardOptions) -> Result<ClipboardContent, String> {
660 let pb = board(b)?;
661 let mut out = ClipboardContent::default();
662 let Some(items) = pb.pasteboardItems() else {
663 return Ok(out);
664 };
665 for item in items.iter() {
666 let types: Vec<String> = item.types().iter().map(|t| t.to_string()).collect();
667 for t in &types {
668 let ty = NSString::from_str(t);
669 match t.as_str() {
670 T_TEXT if out.text.is_none() => {
671 out.text = item.stringForType(&ty).map(|s| s.to_string())
672 }
673 T_HTML if out.html.is_none() => {
674 out.html = item.stringForType(&ty).map(|s| s.to_string())
675 }
676 T_PNG if out.png.is_none() => {
677 out.png = item.dataForType(&ty).map(|d| d.to_vec())
678 }
679 T_FILE => {
680 if let Some(s) = item.stringForType(&ty) {
681 if let Some(url) = NSURL::URLWithString(&s) {
682 if let Some(p) = url.path() {
683 out.files.push(p.to_string());
684 }
685 }
686 }
687 }
688 _ => {
689 let dynamic = t.starts_with("dyn.");
690 let known = matches!(
691 t.as_str(),
692 T_TEXT
693 | T_HTML
694 | T_PNG
695 | T_FILE
696 | "NSStringPboardType"
697 | "public.url"
698 | "public.url-name"
699 | "public.utf16-external-plain-text"
700 | "public.plain-text"
701 | "public.text"
702 );
703 if let (Some(max), false, false) =
704 (options.custom_max_bytes, known, dynamic)
705 {
706 if let Some(d) = item.dataForType(&ty) {
707 if d.len() <= max {
708 out.custom.entry(t.clone()).or_insert_with(|| d.to_vec());
709 }
710 }
711 }
712 }
713 }
714 }
715 }
716 if out.text.is_none() {
717 for ty in TEXT_FALLBACKS {
718 if let Some(text) = pb.stringForType(&NSString::from_str(ty)) {
719 out.text = Some(text.to_string());
720 break;
721 }
722 }
723 }
724 let _ = ns_string!("");
725 Ok(out)
726 }
727
728 pub fn formats(b: &Board) -> Result<Vec<String>, String> {
729 Ok(board(b)?
730 .types()
731 .map(|t| t.iter().map(|s| s.to_string()).collect())
732 .unwrap_or_default())
733 }
734}
735
736#[cfg(windows)]
737mod imp {
738 use super::*;
742 use crate::winapi::windows::core::PCWSTR;
743 use crate::winapi::windows::Win32::Foundation::{GlobalFree, HANDLE, HGLOBAL, HWND};
744 use crate::winapi::windows::Win32::System::DataExchange::*;
745 use crate::winapi::windows::Win32::System::Memory::{
746 GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE,
747 };
748 use crate::winapi::windows::Win32::UI::Shell::{DragQueryFileW, HDROP};
749 use crate::winapi::windows::Win32::UI::WindowsAndMessaging::{
750 CreateWindowExW, DestroyWindow, HWND_MESSAGE, WINDOW_EX_STYLE, WINDOW_STYLE,
751 };
752
753 const CF_UNICODETEXT: u32 = 13;
754 const CF_HDROP: u32 = 15;
755 const CF_DIB: u32 = 8;
756
757 struct Owner(HWND);
762 impl Owner {
763 fn create() -> Option<Self> {
764 let class = wide("STATIC");
765 let hwnd = unsafe {
766 CreateWindowExW(
767 WINDOW_EX_STYLE::default(),
768 PCWSTR(class.as_ptr()),
769 PCWSTR::null(),
770 WINDOW_STYLE::default(),
771 0,
772 0,
773 0,
774 0,
775 Some(HWND_MESSAGE),
776 None,
777 None,
778 None,
779 )
780 }
781 .ok()?;
782 Some(Owner(hwnd))
783 }
784 }
785 impl Drop for Owner {
786 fn drop(&mut self) {
787 let _ = unsafe { DestroyWindow(self.0) };
788 }
789 }
790
791 struct Open;
792 impl Open {
793 fn new(owner: Option<HWND>) -> Result<Self, String> {
794 for _ in 0..10 {
796 if unsafe { OpenClipboard(owner) }.is_ok() {
797 return Ok(Open);
798 }
799 std::thread::sleep(std::time::Duration::from_millis(20));
800 }
801 Err("clipboard is busy".into())
802 }
803 }
804 impl Drop for Open {
805 fn drop(&mut self) {
806 let _ = unsafe { CloseClipboard() };
807 }
808 }
809
810 fn wide(s: &str) -> Vec<u16> {
811 s.encode_utf16().chain(std::iter::once(0)).collect()
812 }
813 fn format(name: &str) -> u32 {
814 let w = wide(name);
815 unsafe { RegisterClipboardFormatW(PCWSTR(w.as_ptr())) }
816 }
817 fn set(fmt: u32, bytes: &[u8]) -> Result<(), String> {
818 unsafe {
819 let h: HGLOBAL =
820 GlobalAlloc(GMEM_MOVEABLE, bytes.len().max(1)).map_err(|e| e.to_string())?;
821 let p = GlobalLock(h) as *mut u8;
822 if p.is_null() {
823 let _ = GlobalFree(Some(h));
824 return Err("GlobalLock failed".into());
825 }
826 std::ptr::copy_nonoverlapping(bytes.as_ptr(), p, bytes.len());
827 let _ = GlobalUnlock(h);
828 match SetClipboardData(fmt, Some(HANDLE(h.0))) {
831 Ok(_) => Ok(()),
832 Err(e) => {
833 let _ = GlobalFree(Some(h));
834 Err(e.to_string())
835 }
836 }
837 }
838 }
839 fn get(fmt: u32) -> Option<Vec<u8>> {
840 unsafe {
841 let h = GetClipboardData(fmt).ok()?;
842 let g = HGLOBAL(h.0);
843 let size = GlobalSize(g);
844 let p = GlobalLock(g) as *const u8;
845 if p.is_null() {
846 return None;
847 }
848 let v = std::slice::from_raw_parts(p, size).to_vec();
849 let _ = GlobalUnlock(g);
850 Some(v)
851 }
852 }
853 fn utf16_bytes(s: &str) -> Vec<u8> {
854 wide(s).into_iter().flat_map(|u| u.to_le_bytes()).collect()
855 }
856 fn cf_html(fragment: &str) -> Vec<u8> {
858 let pre = "<html><body><!--StartFragment-->";
859 let post = "<!--EndFragment--></body></html>";
860 let header = |a: usize, b: usize, c: usize, d: usize| {
861 format!("Version:0.9\r\nStartHTML:{a:010}\r\nEndHTML:{b:010}\r\nStartFragment:{c:010}\r\nEndFragment:{d:010}\r\n")
862 };
863 let hl = header(0, 0, 0, 0).len();
864 let sh = hl;
865 let sf = hl + pre.len();
866 let ef = sf + fragment.len();
867 let eh = ef + post.len();
868 format!("{}{pre}{fragment}{post}", header(sh, eh, sf, ef)).into_bytes()
869 }
870 fn hdrop(files: &[String]) -> Vec<u8> {
871 let mut out = vec![0u8; 20];
873 out[0..4].copy_from_slice(&20u32.to_le_bytes());
874 out[16..20].copy_from_slice(&1u32.to_le_bytes());
875 for f in files {
876 out.extend(utf16_bytes(f));
877 }
878 out.extend([0, 0]);
879 out
880 }
881
882 pub fn write(b: &Board, c: &ClipboardContent) -> Result<(), String> {
883 if matches!(b, Board::Named(_)) {
884 return Err("named boards are macOS-only".into());
885 }
886 let owner = Owner::create().ok_or("could not create the clipboard owner window")?;
890 let _open = Open::new(Some(owner.0))?;
891 unsafe { EmptyClipboard() }.map_err(|e| e.to_string())?;
892 if let Some(t) = &c.text {
893 set(CF_UNICODETEXT, &utf16_bytes(t))?;
894 }
895 if let Some(h) = &c.html {
896 set(format("HTML Format"), &cf_html(h))?;
897 }
898 if let Some(p) = &c.png {
899 set(format("PNG"), p)?;
900 }
901 if !c.files.is_empty() {
902 set(CF_HDROP, &hdrop(&c.files))?;
903 }
904 for (k, v) in &c.custom {
905 set(format(k), v)?;
906 }
907 Ok(())
908 }
909
910 pub fn read(b: &Board, _options: &ClipboardOptions) -> Result<ClipboardContent, String> {
911 if matches!(b, Board::Named(_)) {
912 return Err("named boards are macOS-only".into());
913 }
914 let _open = Open::new(None)?;
915 let mut out = ClipboardContent::default();
916 if let Some(raw) = get(CF_UNICODETEXT) {
917 let u: Vec<u16> = raw
918 .as_chunks::<2>()
919 .0
920 .iter()
921 .map(|p| u16::from_le_bytes([p[0], p[1]]))
922 .take_while(|u| *u != 0)
923 .collect();
924 out.text = Some(String::from_utf16_lossy(&u));
925 }
926 if let Some(raw) = get(format("HTML Format")) {
927 let s = String::from_utf8_lossy(&raw).into_owned();
928 let field = |k: &str| {
929 s.split("\r\n")
930 .find_map(|l| l.strip_prefix(k))
931 .and_then(|v| v.trim().parse::<usize>().ok())
932 };
933 if let (Some(a), Some(z)) = (field("StartFragment:"), field("EndFragment:")) {
934 out.html = s.get(a..z).map(str::to_owned);
935 }
936 }
937 out.png = get(format("PNG"));
938 unsafe {
939 if let Ok(h) = GetClipboardData(CF_HDROP) {
940 let drop = HDROP(h.0);
941 let n = DragQueryFileW(drop, u32::MAX, None);
942 for i in 0..n {
943 let len = DragQueryFileW(drop, i, None) as usize;
944 let mut buf = vec![0u16; len + 1];
945 DragQueryFileW(drop, i, Some(&mut buf));
946 out.files.push(String::from_utf16_lossy(&buf[..len]));
947 }
948 }
949 }
950 Ok(out)
951 }
952
953 pub fn dib_bytes() -> Result<Option<Vec<u8>>, String> {
955 let _open = Open::new(None)?;
956 Ok(get(CF_DIB))
957 }
958
959 pub fn formats(b: &Board) -> Result<Vec<String>, String> {
960 if matches!(b, Board::Named(_)) {
962 return Err("named boards are macOS-only".into());
963 }
964 let _open = Open::new(None)?;
965 let mut out = Vec::new();
966 let mut f = 0u32;
967 loop {
968 f = unsafe { EnumClipboardFormats(f) };
969 if f == 0 {
970 break;
971 }
972 let mut buf = [0u16; 128];
973 let n = unsafe { GetClipboardFormatNameW(f, &mut buf) };
974 out.push(if n > 0 {
975 String::from_utf16_lossy(&buf[..n as usize])
976 } else {
977 format!("cf-{f}")
978 });
979 }
980 Ok(out)
981 }
982}
983
984#[cfg(not(any(target_os = "macos", windows)))]
985mod imp {
986 use super::*;
987 pub fn write(_: &Board, _: &ClipboardContent) -> Result<(), String> {
988 Err("rich clipboard is not implemented on this platform".into())
989 }
990 pub fn read(_: &Board, _: &ClipboardOptions) -> Result<ClipboardContent, String> {
991 Err("rich clipboard is not implemented on this platform".into())
992 }
993 pub fn formats(_: &Board) -> Result<Vec<String>, String> {
994 Err("rich clipboard is not implemented on this platform".into())
995 }
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001
1002 fn types(list: &[&str]) -> Vec<String> {
1003 list.iter().map(|t| (*t).to_owned()).collect()
1004 }
1005
1006 fn named() -> Board {
1007 Board::Named("rightkit-shell-test-unit".into())
1008 }
1009
1010 #[test]
1011 fn only_the_general_board_may_use_the_applescript_fallbacks() {
1012 assert!(fallback_allowed(&Board::General));
1013 assert!(!fallback_allowed(&named()));
1014 }
1015
1016 #[test]
1017 fn the_general_board_selects_the_fallbacks_as_before() {
1018 let general = Board::General;
1019 assert!(needs_file_fallback(
1020 &general,
1021 false,
1022 &types(&["public.file-url"])
1023 ));
1024 assert!(!needs_file_fallback(
1026 &general,
1027 true,
1028 &types(&["public.file-url"])
1029 ));
1030 assert!(!needs_file_fallback(
1031 &general,
1032 false,
1033 &types(&["public.png"])
1034 ));
1035 assert!(needs_png_fallback(&general, &types(&["public.tiff"])));
1036 assert!(needs_png_fallback(&general, &types(&["public.image"])));
1037 assert!(!needs_png_fallback(
1038 &general,
1039 &types(&["public.utf8-plain-text"])
1040 ));
1041 }
1042
1043 #[test]
1044 fn a_named_board_never_selects_a_fallback() {
1045 let board = named();
1046 for list in [
1047 &["public.file-url"][..],
1048 &["public.tiff"],
1049 &["public.image"],
1050 &[
1051 "public.file-url",
1052 "public.tiff",
1053 "public.image",
1054 "public.png",
1055 ],
1056 &[],
1057 ] {
1058 let t = types(list);
1059 assert!(!needs_file_fallback(&board, false, &t), "{list:?}");
1060 assert!(!needs_file_fallback(&board, true, &t), "{list:?}");
1061 assert!(!needs_png_fallback(&board, &t), "{list:?}");
1062 }
1063 }
1064
1065 #[test]
1066 fn system_pasteboard_names_are_refused() {
1067 for name in [
1068 "Apple CFPasteboard general",
1069 "Apple CFPasteboard find",
1070 "Apple CFPasteboard drag",
1071 "apple cfpasteboard general",
1072 " Apple CFPasteboard general",
1073 "",
1074 " ",
1075 ] {
1076 assert!(named_board_refusal(name).is_some(), "{name:?}");
1077 }
1078 assert!(named_board_refusal("rightkit-shell-test-1-0").is_none());
1079 assert!(named_board_refusal("viewright-paste-test").is_none());
1080 }
1081
1082 #[test]
1085 fn the_general_board_alias_is_unreachable_through_a_name() {
1086 let alias = Board::Named("Apple CFPasteboard general".into());
1087 assert!(read(&alias).is_err());
1088 assert!(formats(&alias).is_err());
1089 let content = ClipboardContent {
1090 text: Some("must never be written".into()),
1091 ..ClipboardContent::default()
1092 };
1093 assert!(write(&alias, &content).is_err());
1094 let dir = std::env::temp_dir().join("rightkit-shell-test-alias-unused");
1095 assert!(take_item_from_with(
1096 &alias,
1097 &PathBroker::new(),
1098 &dir,
1099 ClipboardPolicy::default(),
1100 &ClipboardOptions::default(),
1101 )
1102 .is_err());
1103 assert!(!dir.exists());
1104 }
1105
1106 #[cfg(not(target_os = "macos"))]
1107 #[test]
1108 fn a_named_board_is_rejected_where_there_is_no_private_clipboard() {
1109 let dir = std::env::temp_dir().join("rightkit-shell-test-named-unused");
1110 let result = take_item_from_with(
1111 &named(),
1112 &PathBroker::new(),
1113 &dir,
1114 ClipboardPolicy::default(),
1115 &ClipboardOptions::default(),
1116 );
1117 assert!(result.is_err(), "{result:?}");
1118 assert!(!dir.exists());
1119 }
1120
1121 #[cfg(target_os = "macos")]
1126 mod private_board {
1127 use super::*;
1128
1129 struct TestBoard(Board);
1130
1131 impl TestBoard {
1132 fn new() -> Self {
1133 static COUNTER: AtomicU64 = AtomicU64::new(0);
1134 Self(Board::Named(format!(
1135 "rightkit-shell-test-{}-{}",
1136 std::process::id(),
1137 COUNTER.fetch_add(1, Ordering::Relaxed)
1138 )))
1139 }
1140 fn take(&self, temp_dir: &Path) -> Result<ClipboardItem, ShellError> {
1141 take_item_from_with(
1142 &self.0,
1143 &PathBroker::new(),
1144 temp_dir,
1145 ClipboardPolicy::default(),
1146 &ClipboardOptions::default(),
1147 )
1148 }
1149 }
1150
1151 impl Drop for TestBoard {
1152 fn drop(&mut self) {
1153 imp::release_named(&self.0);
1154 }
1155 }
1156
1157 #[test]
1158 fn text_written_to_a_named_board_is_taken_from_it() {
1159 let board = TestBoard::new();
1160 let dir = tempfile::tempdir().unwrap();
1161 write(
1162 &board.0,
1163 &ClipboardContent {
1164 text: Some("private paste text".into()),
1165 html: Some("<b>private</b>".into()),
1166 ..ClipboardContent::default()
1167 },
1168 )
1169 .unwrap();
1170 assert_eq!(
1171 board.take(dir.path()).unwrap(),
1172 ClipboardItem::Text {
1173 text: "private paste text".into()
1174 }
1175 );
1176 }
1177
1178 #[test]
1179 fn an_empty_named_board_is_none_not_the_general_clipboard() {
1180 let board = TestBoard::new();
1181 let dir = tempfile::tempdir().unwrap();
1182 assert_eq!(board.take(dir.path()).unwrap(), ClipboardItem::None);
1183 }
1184
1185 #[test]
1186 fn a_named_board_with_only_an_unreadable_bitmap_type_is_none_with_no_fallback() {
1187 let board = TestBoard::new();
1188 let dir = tempfile::tempdir().unwrap();
1189 let mut custom = BTreeMap::new();
1190 custom.insert("public.tiff".to_owned(), b"not really a tiff".to_vec());
1191 write(
1192 &board.0,
1193 &ClipboardContent {
1194 custom,
1195 ..ClipboardContent::default()
1196 },
1197 )
1198 .unwrap();
1199 assert_eq!(board.take(dir.path()).unwrap(), ClipboardItem::None);
1201 assert!(std::fs::read_dir(dir.path()).unwrap().next().is_none());
1203 }
1204
1205 #[test]
1206 fn files_and_images_on_a_named_board_are_granted() {
1207 let dir = tempfile::tempdir().unwrap();
1208 let file = dir.path().join("pasted.txt");
1209 std::fs::write(&file, b"hello").unwrap();
1210
1211 let files = TestBoard::new();
1212 write(
1213 &files.0,
1214 &ClipboardContent {
1215 files: vec![file.to_string_lossy().into_owned()],
1216 ..ClipboardContent::default()
1217 },
1218 )
1219 .unwrap();
1220 match files.take(dir.path()).unwrap() {
1221 ClipboardItem::Files { paths } => {
1222 assert_eq!(paths.len(), 1);
1223 assert!(paths[0].ends_with("pasted.txt"), "{paths:?}");
1224 }
1225 other => panic!("expected files, got {other:?}"),
1226 }
1227
1228 let image = TestBoard::new();
1229 let png = b"\x89PNG\r\n\x1a\nstand-in bytes".to_vec();
1230 write(
1231 &image.0,
1232 &ClipboardContent {
1233 png: Some(png.clone()),
1234 ..ClipboardContent::default()
1235 },
1236 )
1237 .unwrap();
1238 match image.take(dir.path()).unwrap() {
1239 ClipboardItem::Image { path } => {
1240 assert_eq!(std::fs::read(&path).unwrap(), png);
1241 }
1242 other => panic!("expected an image, got {other:?}"),
1243 }
1244 }
1245 }
1246}