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