Skip to main content

rightkit_tauri_shell/
clipboard.rs

1//! Rich clipboard: text, HTML, PNG image, file lists and app-defined formats, in one transaction,
2//! plus a typed "what did the person copy" contract for paste and open-from-clipboard.
3//!
4//! Donors: HeardRight `clipboard` + delivery (text/HTML/image, paste-sequence discipline),
5//! ViewRight file grants (paths, not bytes) and ViewRight `commands/clipboard.rs` (typed
6//! files/image/text/empty result, CF_DIB to BMP, temp-file grants, one-hour purge).
7//!
8//! * [`read`] / [`write`] are the raw transaction API. macOS uses `NSPasteboard` (one primary item
9//!   carrying every representation, plus one item per file, which is how Finder lays files out);
10//!   Windows uses the Win32 clipboard (`CF_UNICODETEXT`, `HTML Format`, `PNG`, `CF_HDROP`).
11//!   `Board::Named` targets a private pasteboard so tests and apps can stage content without
12//!   touching what the user copied (macOS only).
13//! * [`take_item`] is the typed contract the `rk_shell_clipboard_item` command returns. Priority is
14//!   files, then a bitmap (written to a granted temp file), then text, then nothing. Files and
15//!   temp images are granted through the [`PathBroker`]; nothing else reaches the renderer.
16//!   [`item_from_snapshot`] runs the same policy on any snapshot, so it is testable on every host.
17//! * [`ClipboardOptions`] (read side, all optional): the Windows bitmap format preference
18//!   ([`ImagePreference`]) and the opt-in capture of unknown formats ([`ClipboardOptions::custom_max_bytes`]).
19//!   [`read_with`] and [`take_item_with`] take them; [`read`] and [`take_item`] use the defaults.
20//! * [`take_item_from_with`] is [`take_item_with`] for an explicit [`Board`]. A `Board::Named` read
21//!   touches only that private pasteboard: the AppleScript fallbacks (which can only see the general
22//!   clipboard) are off for it and nothing falls back to the general pasteboard. Windows has no
23//!   private clipboard, so a named board is rejected there.
24
25use 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    /// Absolute paths.
47    pub files: Vec<String>,
48    /// App-defined formats, keyed by UTI / registered format name.
49    pub custom: BTreeMap<String, Vec<u8>>,
50}
51
52/// Default age after which an earlier clipboard temp file is purged.
53pub const DEFAULT_PURGE_AFTER: Duration = Duration::from_secs(60 * 60);
54
55/// Typed result of reading the clipboard for an "open" or "paste" action. Serialises as
56/// `{"kind": "files" | "image" | "text" | "none", ...}`.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58#[serde(tag = "kind", rename_all = "lowercase")]
59pub enum ClipboardItem {
60    /// Files copied in the file manager. Each path is granted to the broker.
61    Files { paths: Vec<String> },
62    /// A bitmap, written to a granted temp file (`.png` when the clipboard had PNG, else `.bmp`).
63    Image { path: String },
64    /// Plain Unicode text.
65    Text { text: String },
66    /// Nothing usable.
67    None,
68}
69
70/// Which Windows bitmap format wins when the clipboard offers both `PNG` and `CF_DIB`.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum ImagePreference {
73    /// `PNG` (lossless, keeps alpha) when the copier provided it, else `CF_DIB` as BMP.
74    #[default]
75    PngFirst,
76    /// `CF_DIB` as BMP when present, else `PNG`. ViewRight's behaviour: every copier offers `CF_DIB`.
77    DibFirst,
78}
79
80/// Read-side clipboard options. Defaults reproduce the pre-option behaviour except that unknown
81/// formats are no longer copied (see [`ClipboardOptions::custom_max_bytes`]).
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub struct ClipboardOptions {
84    /// Windows only: `PNG` or `CF_DIB` first for the typed image (`take_item_with`). macOS always
85    /// takes PNG, coercing from TIFF when needed.
86    pub image_preference: ImagePreference,
87    /// macOS only: also copy the bytes of pasteboard types the reader does not recognise into
88    /// [`ClipboardContent::custom`], skipping any type larger than this many bytes. `None` (the
89    /// default) copies nothing: a pasteboard can carry arbitrary private data, and a reader that
90    /// wants it must ask. Dynamic (`dyn.`) types are never copied. Windows never reads unknown formats.
91    pub custom_max_bytes: Option<usize>,
92}
93
94/// A bitmap taken from the clipboard, with the extension its temp file should carry.
95#[derive(Debug, Clone, PartialEq, Eq, Default)]
96pub struct ClipboardImage {
97    pub extension: String,
98    pub bytes: Vec<u8>,
99}
100
101/// What the platform clipboard held, before the priority policy runs.
102#[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/// Clipboard temp-file policy.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct ClipboardPolicy {
112    /// Temp files older than this are removed before a new one is written.
113    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
124/// The per-app temp directory for clipboard images: `<temp>/<app_id>-clipboard`.
125pub 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}
132/// Read the board with default [`ClipboardOptions`] (unknown formats are not copied).
133pub fn read(board: &Board) -> Result<ClipboardContent, String> {
134    imp::read(board, &ClipboardOptions::default())
135}
136/// Read the board with explicit [`ClipboardOptions`].
137pub fn read_with(board: &Board, options: &ClipboardOptions) -> Result<ClipboardContent, String> {
138    imp::read(board, options)
139}
140/// Raw type/format names currently on the board (diagnostics).
141pub fn formats(board: &Board) -> Result<Vec<String>, String> {
142    imp::formats(board)
143}
144
145/// Read the clipboard and apply the priority policy: files, then a bitmap, then text.
146pub 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
154/// [`take_item`] with explicit [`ClipboardOptions`] (for example `DibFirst` on Windows). Reads the
155/// general clipboard: it is [`take_item_from_with`] for `Board::General`.
156pub 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
165/// [`take_item_with`] for an explicit [`Board`].
166///
167/// `Board::General` is the user's clipboard and behaves exactly as [`take_item_with`].
168/// `Board::Named` reads only that private pasteboard (macOS): the AppleScript fallbacks for a file
169/// URL without a path and for a PNG coerced from a TIFF are skipped, because AppleScript can only
170/// see the general clipboard, and no failure or empty result falls through to it. A missing
171/// representation yields [`ClipboardItem::None`] or an error for the named board, never the user's
172/// clipboard. A name in the `Apple CFPasteboard ...` family (the system boards, including the
173/// general one) is rejected. On Windows and other platforms a named board is rejected with the
174/// reader's error, since there is no private clipboard.
175pub 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/// Whether the AppleScript fallbacks may run for this board. They read the general clipboard, so
187/// only `Board::General` may use them.
188#[cfg(any(target_os = "macos", test))]
189fn fallback_allowed(board: &Board) -> bool {
190    matches!(board, Board::General)
191}
192
193/// The API saw a file-URL item but produced no path: ask AppKit through osascript (general only).
194#[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/// Only a non-PNG bitmap (TIFF, or an image type) needs AppleScript's PNG coercion (general only).
200#[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/// Why a private pasteboard name is refused, or `None` when it is usable. The `Apple CFPasteboard`
209/// family names the system boards (`general`, `find`, `font`, `ruler`, `drag`); asking AppKit for
210/// one by name would hand back the user's own pasteboard. Pure and available on every platform, so a
211/// caller (and the QA probes in [`qa`]) can refuse a name before any pasteboard is touched.
212pub 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/// QA-only control-command helpers for private pasteboards (feature `qa-probes`).
227#[cfg(all(feature = "qa-probes", debug_assertions))]
228pub mod qa;
229
230/// Apply the priority policy to a snapshot: grant the files that exist, write a bitmap to a granted
231/// temp file, otherwise return non-empty text. Old temp files are purged first.
232pub 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
267/// Remove regular files in `dir` last modified more than `older_than` ago. Returns how many were
268/// removed. A missing directory or an unreadable entry is skipped, never an error.
269pub 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
294/// Write `bytes` to a fresh `clipboard-<stamp>.<ext>` file in `dir` (created if needed).
295fn 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
331/// Convert a `CF_DIB` blob (a BITMAPINFO plus pixels) to a complete BMP file by prepending the
332/// 14-byte file header. Returns `None` for a blob too short or inconsistent to be a DIB.
333pub 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    // BI_BITFIELDS (3) and BI_ALPHABITFIELDS (6) store colour masks after a 40-byte
345    // BITMAPINFOHEADER. The larger V4/V5 (and 52/56-byte) headers carry the masks inside the header.
346    let mask_bytes = match (header_size, compression) {
347        (40, 3) => 12,
348        (40, 6) => 16,
349        _ => 0,
350    };
351    // A bit count of 0 (BI_JPEG, BI_PNG) has no colour table.
352    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    // The fallbacks below are general-clipboard only: `needs_*_fallback` is false for a named board.
387    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    // Windows has no private clipboard: the reader rejects a named board.
419    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        // PNG when the copier provided it (lossless, alpha); otherwise the classic CF_DIB as BMP.
434        ImagePreference::PngFirst => match png {
435            Some(png) => Some(png),
436            None => bmp()?,
437        },
438        // CF_DIB when it converts; a PNG-only clipboard still yields its PNG.
439        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        // No private clipboard here: surface the reader's rejection.
459        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    //! AppleScript fallbacks for the two reads the AppKit bindings here cannot do directly: a
470    //! file URL the API could not turn into a path, and a PNG coerced from a TIFF or other bitmap.
471    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    /// File paths on the general clipboard. Empty, without running anything, for a named board:
489    /// the script can only read the general pasteboard.
490    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    /// The general clipboard image as PNG bytes, staged in `dir` and removed after reading. `None`,
517    /// without running anything, for a named board: `the clipboard` is the general pasteboard.
518    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    /// Plain-text types tried, in order, when no item carried `public.utf8-plain-text`. The first is
579    /// asked of the board (`NSPasteboardTypeString`), which lets AppKit coerce from the other text
580    /// forms, as `pbpaste -Prefer txt` does; the rest are the legacy and sibling types.
581    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    /// The pasteboard for a board. A name that would resolve to a system board (the general
590    /// clipboard included) is refused rather than handed to AppKit.
591    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    /// Release a named test pasteboard in the pasteboard server. No-op for the general board.
604    #[cfg(test)]
605    pub fn release_named(b: &Board) {
606        if matches!(b, Board::Named(_)) {
607            if let Ok(pb) = board(b) {
608                // SAFETY: `releaseGlobally` takes no arguments and returns void; the board is not used
609                // again by the caller.
610                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    //! Win32 clipboard backend. Not compiled or run on the macOS development host; the windows-2025
739    //! CI job compiles it. Behaviour against a live Windows clipboard has not been exercised here, so
740    //! treat the paths below as written against the API documentation, not field-tested.
741    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    /// A message-only window that owns the clipboard while we write. `OpenClipboard(NULL)` followed
758    /// by `EmptyClipboard` leaves the clipboard with a NULL owner, and `SetClipboardData` then fails
759    /// (documented Win32 behaviour), so a write needs a real owner. Real data handles outlive the
760    /// window, so destroying it after the write does not clear what was set.
761    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            // The clipboard can be briefly held by another process: retry a few times.
795            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            // Ownership of `h` passes to the clipboard on success only: on failure it is still
829            // ours and must be freed.
830            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    /// CF_HTML wrapper with correct byte offsets.
857    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        // DROPFILES { pFiles: u32 = 20, pt: POINT(0,0), fNC: BOOL(0), fWide: BOOL(1) } + double-NUL list.
872        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        // Declared before `_open`, so it is dropped (destroyed) after the clipboard is closed. A
887        // write needs an owner window: with a NULL owner `EmptyClipboard` leaves the clipboard
888        // ownerless and every `SetClipboardData` after it fails.
889        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    /// The raw `CF_DIB` blob (BITMAPINFO plus pixels), copied out under its own open/close.
954    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        // Windows has one clipboard: a named board must not answer with the user's formats.
961        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        // A path the API already produced needs no fallback.
1025        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    /// The system-board alias is refused by every raw entry point on every platform, before any
1083    /// pasteboard is touched (macOS) or because named boards do not exist (elsewhere).
1084    #[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    /// macOS: every test below stages content on a uniquely named private pasteboard and reads it
1122    /// back with `take_item_from_with`. None reads or writes the general pasteboard: the board is
1123    /// always `Board::Named`, and the guard releases it afterwards. These need the pasteboard
1124    /// server, like any NSPasteboard use; the crate has no earlier macOS pasteboard test to mirror.
1125    #[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            // The general path would hand this to AppleScript's `the clipboard as PNG`.
1200            assert_eq!(board.take(dir.path()).unwrap(), ClipboardItem::None);
1201            // The fallback would have staged a file here; none was.
1202            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}