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.
211#[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
226/// Apply the priority policy to a snapshot: grant the files that exist, write a bitmap to a granted
227/// temp file, otherwise return non-empty text. Old temp files are purged first.
228pub 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
263/// Remove regular files in `dir` last modified more than `older_than` ago. Returns how many were
264/// removed. A missing directory or an unreadable entry is skipped, never an error.
265pub 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
290/// Write `bytes` to a fresh `clipboard-<stamp>.<ext>` file in `dir` (created if needed).
291fn 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
327/// Convert a `CF_DIB` blob (a BITMAPINFO plus pixels) to a complete BMP file by prepending the
328/// 14-byte file header. Returns `None` for a blob too short or inconsistent to be a DIB.
329pub 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    // BI_BITFIELDS (3) and BI_ALPHABITFIELDS (6) store colour masks after a 40-byte
341    // BITMAPINFOHEADER. The larger V4/V5 (and 52/56-byte) headers carry the masks inside the header.
342    let mask_bytes = match (header_size, compression) {
343        (40, 3) => 12,
344        (40, 6) => 16,
345        _ => 0,
346    };
347    // A bit count of 0 (BI_JPEG, BI_PNG) has no colour table.
348    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    // The fallbacks below are general-clipboard only: `needs_*_fallback` is false for a named board.
383    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    // Windows has no private clipboard: the reader rejects a named board.
415    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        // PNG when the copier provided it (lossless, alpha); otherwise the classic CF_DIB as BMP.
430        ImagePreference::PngFirst => match png {
431            Some(png) => Some(png),
432            None => bmp()?,
433        },
434        // CF_DIB when it converts; a PNG-only clipboard still yields its PNG.
435        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        // No private clipboard here: surface the reader's rejection.
455        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    //! AppleScript fallbacks for the two reads the AppKit bindings here cannot do directly: a
466    //! file URL the API could not turn into a path, and a PNG coerced from a TIFF or other bitmap.
467    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    /// File paths on the general clipboard. Empty, without running anything, for a named board:
485    /// the script can only read the general pasteboard.
486    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    /// The general clipboard image as PNG bytes, staged in `dir` and removed after reading. `None`,
513    /// without running anything, for a named board: `the clipboard` is the general pasteboard.
514    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    /// Plain-text types tried, in order, when no item carried `public.utf8-plain-text`. The first is
575    /// asked of the board (`NSPasteboardTypeString`), which lets AppKit coerce from the other text
576    /// forms, as `pbpaste -Prefer txt` does; the rest are the legacy and sibling types.
577    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    /// The pasteboard for a board. A name that would resolve to a system board (the general
586    /// clipboard included) is refused rather than handed to AppKit.
587    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    /// Release a named test pasteboard in the pasteboard server. No-op for the general board.
600    #[cfg(test)]
601    pub fn release_named(b: &Board) {
602        if matches!(b, Board::Named(_)) {
603            if let Ok(pb) = board(b) {
604                // SAFETY: `releaseGlobally` takes no arguments and returns void; the board is not used
605                // again by the caller.
606                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    //! Win32 clipboard backend. Not compiled or run on the macOS development host; the windows-2025
735    //! CI job compiles it. Behaviour against a live Windows clipboard has not been exercised here, so
736    //! treat the paths below as written against the API documentation, not field-tested.
737    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    /// A message-only window that owns the clipboard while we write. `OpenClipboard(NULL)` followed
754    /// by `EmptyClipboard` leaves the clipboard with a NULL owner, and `SetClipboardData` then fails
755    /// (documented Win32 behaviour), so a write needs a real owner. Real data handles outlive the
756    /// window, so destroying it after the write does not clear what was set.
757    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            // The clipboard can be briefly held by another process: retry a few times.
791            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            // Ownership of `h` passes to the clipboard on success only: on failure it is still
825            // ours and must be freed.
826            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    /// CF_HTML wrapper with correct byte offsets.
853    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        // DROPFILES { pFiles: u32 = 20, pt: POINT(0,0), fNC: BOOL(0), fWide: BOOL(1) } + double-NUL list.
868        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        // Declared before `_open`, so it is dropped (destroyed) after the clipboard is closed. A
883        // write needs an owner window: with a NULL owner `EmptyClipboard` leaves the clipboard
884        // ownerless and every `SetClipboardData` after it fails.
885        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    /// The raw `CF_DIB` blob (BITMAPINFO plus pixels), copied out under its own open/close.
950    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        // Windows has one clipboard: a named board must not answer with the user's formats.
957        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        // A path the API already produced needs no fallback.
1021        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    /// The system-board alias is refused by every raw entry point on every platform, before any
1079    /// pasteboard is touched (macOS) or because named boards do not exist (elsewhere).
1080    #[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    /// macOS: every test below stages content on a uniquely named private pasteboard and reads it
1118    /// back with `take_item_from_with`. None reads or writes the general pasteboard: the board is
1119    /// always `Board::Named`, and the guard releases it afterwards. These need the pasteboard
1120    /// server, like any NSPasteboard use; the crate has no earlier macOS pasteboard test to mirror.
1121    #[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            // The general path would hand this to AppleScript's `the clipboard as PNG`.
1196            assert_eq!(board.take(dir.path()).unwrap(), ClipboardItem::None);
1197            // The fallback would have staged a file here; none was.
1198            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}