309 lines
12 KiB
Rust
309 lines
12 KiB
Rust
//! Platform-native clipboard integration for custom MIME types.
|
|
//!
|
|
//! > **Temporary shim** — this module exists because arboard does not yet support
|
|
//! > custom MIME types. Once arboard gains that capability (tracked at
|
|
//! > <https://github.com/1Password/arboard/issues/14>) this module and all
|
|
//! > platform-conditional deps (`objc2*`, `windows-sys`, `wl-clipboard-rs`,
|
|
//! > `x11-clipboard`) should be removed and replaced with a single arboard call.
|
|
//!
|
|
//! Provides [`set`] and [`get`] functions for reading and writing non-text
|
|
//! clipboard formats directly via each platform's native clipboard API.
|
|
//!
|
|
//! # Platform notes
|
|
//! - **macOS**: NSPasteboard via objc2 — appends entries to the clipboard that
|
|
//! arboard already opened; must be called *after* `arboard::set_text()` /
|
|
//! `set_image()` since arboard calls `clearContents` internally.
|
|
//! - **Windows**: `RegisterClipboardFormat` + `SetClipboardData` — appends to
|
|
//! the clipboard arboard already populated; must be called *after* arboard.
|
|
//! - **Linux/Wayland**: `wl-clipboard-rs` — creates its own Wayland connection
|
|
//! and spawns a background thread to serve clipboard requests; no external
|
|
//! tools required.
|
|
//! - **Linux/X11**: `x11-clipboard` — serves custom-atom requests via its
|
|
//! background thread; only the first entry is set (X11 single-target
|
|
//! limitation per selection).
|
|
|
|
/// Set one or more `(mime_type, data)` pairs on the platform clipboard.
|
|
///
|
|
/// On macOS and Windows this must be called **after** `arboard::Clipboard::set_text()` /
|
|
/// `set_image()` because arboard empties the clipboard first.
|
|
///
|
|
/// On Linux/X11 only the first entry is used.
|
|
pub fn set(entries: &[(&str, &[u8])]) {
|
|
platform_impl::set(entries);
|
|
}
|
|
|
|
/// Return the first available `(mime_type, data)` pair from the platform clipboard.
|
|
///
|
|
/// `preferred` is tried in order; the first MIME type with data wins.
|
|
/// Returns `None` when none of the requested types are present.
|
|
pub fn get(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
|
|
platform_impl::get(preferred)
|
|
}
|
|
|
|
// ─────────────────────────────────── macOS ──────────────────────────────────
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod platform_impl {
|
|
use objc2_app_kit::NSPasteboard;
|
|
use objc2_foundation::{NSData, NSString};
|
|
|
|
pub fn set(entries: &[(&str, &[u8])]) {
|
|
let pb = NSPasteboard::generalPasteboard();
|
|
for &(mime, data) in entries {
|
|
let ns_type = NSString::from_str(mime);
|
|
let ns_data = NSData::with_bytes(data);
|
|
// setData:forType: appends to the current clipboard contents
|
|
// (arboard already called clearContents, so no double-clear needed).
|
|
pb.setData_forType(Some(&ns_data), &ns_type);
|
|
}
|
|
}
|
|
|
|
pub fn get(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
|
|
let pb = NSPasteboard::generalPasteboard();
|
|
for &mime in preferred {
|
|
let ns_type = NSString::from_str(mime);
|
|
if let Some(ns_data) = pb.dataForType(&ns_type) {
|
|
// NSData: Deref<Target=[u8]> — auto-deref resolves to [u8]::to_vec().
|
|
let bytes = ns_data.to_vec();
|
|
return Some((mime.to_string(), bytes));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────── Windows ────────────────────────────────
|
|
|
|
#[cfg(target_os = "windows")]
|
|
mod platform_impl {
|
|
use std::collections::HashMap;
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
use windows_sys::Win32::Foundation::HANDLE;
|
|
use windows_sys::Win32::System::DataExchange::{
|
|
CloseClipboard, GetClipboardData, OpenClipboard, RegisterClipboardFormatW, SetClipboardData,
|
|
};
|
|
use windows_sys::Win32::System::Memory::{
|
|
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE,
|
|
};
|
|
|
|
static FORMAT_IDS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
|
|
|
|
/// Register (or look up) the clipboard format ID for a MIME-type string.
|
|
fn registered_format(mime: &str) -> u32 {
|
|
let ids = FORMAT_IDS.get_or_init(|| Mutex::new(HashMap::new()));
|
|
let mut guard = ids.lock().unwrap_or_else(|e| e.into_inner());
|
|
if let Some(&id) = guard.get(mime) {
|
|
return id;
|
|
}
|
|
// RegisterClipboardFormatW requires a null-terminated UTF-16 string.
|
|
let wide: Vec<u16> = mime.encode_utf16().chain(std::iter::once(0)).collect();
|
|
let id = unsafe { RegisterClipboardFormatW(wide.as_ptr()) };
|
|
guard.insert(mime.to_string(), id);
|
|
id
|
|
}
|
|
|
|
pub fn set(entries: &[(&str, &[u8])]) {
|
|
// arboard already called EmptyClipboard; we just append new formats.
|
|
// OpenClipboard(NULL) acquires ownership without clearing.
|
|
unsafe {
|
|
if OpenClipboard(std::ptr::null_mut()) == 0 {
|
|
return;
|
|
}
|
|
for &(mime, data) in entries {
|
|
let fmt = registered_format(mime);
|
|
let h = GlobalAlloc(GMEM_MOVEABLE, data.len());
|
|
if h.is_null() {
|
|
continue;
|
|
}
|
|
let ptr = GlobalLock(h);
|
|
if ptr.is_null() {
|
|
// Cannot free `h` here: GlobalFree was removed from windows-sys 0.60
|
|
// (it still exists in Kernel32.dll, so a manual extern declaration
|
|
// would work if this ever becomes an issue). The leak is bounded to
|
|
// one clipboard-payload-sized allocation and only occurs if GlobalLock
|
|
// fails on a handle we just allocated — essentially impossible in
|
|
// practice.
|
|
continue;
|
|
}
|
|
std::ptr::copy_nonoverlapping(data.as_ptr(), ptr as *mut u8, data.len());
|
|
GlobalUnlock(h);
|
|
SetClipboardData(fmt, h as HANDLE);
|
|
}
|
|
CloseClipboard();
|
|
}
|
|
}
|
|
|
|
pub fn get(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
|
|
unsafe {
|
|
if OpenClipboard(std::ptr::null_mut()) == 0 {
|
|
return None;
|
|
}
|
|
let mut result = None;
|
|
for &mime in preferred {
|
|
let fmt = registered_format(mime);
|
|
let h = GetClipboardData(fmt);
|
|
if h.is_null() {
|
|
continue;
|
|
}
|
|
let ptr = GlobalLock(h as _);
|
|
if ptr.is_null() {
|
|
continue;
|
|
}
|
|
let size = GlobalSize(h as _);
|
|
let data = std::slice::from_raw_parts(ptr as *const u8, size).to_vec();
|
|
GlobalUnlock(h as _);
|
|
result = Some((mime.to_string(), data));
|
|
break;
|
|
}
|
|
CloseClipboard();
|
|
result
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────── Linux ──────────────────────────────────
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod platform_impl {
|
|
pub fn set(entries: &[(&str, &[u8])]) {
|
|
if std::env::var("WAYLAND_DISPLAY").is_ok() {
|
|
set_wayland(entries);
|
|
} else {
|
|
set_x11(entries);
|
|
}
|
|
}
|
|
|
|
pub fn get(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
|
|
if std::env::var("WAYLAND_DISPLAY").is_ok() {
|
|
get_wayland(preferred)
|
|
} else {
|
|
get_x11(preferred)
|
|
}
|
|
}
|
|
|
|
// ── Wayland ──────────────────────────────────────────────────────────────
|
|
|
|
fn set_wayland(entries: &[(&str, &[u8])]) {
|
|
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
|
|
|
|
let sources: Vec<MimeSource> = entries
|
|
.iter()
|
|
.map(|&(mime, data)| MimeSource {
|
|
source: Source::Bytes(data.to_vec().into_boxed_slice()),
|
|
mime_type: MimeType::Specific(mime.to_string()),
|
|
})
|
|
.collect();
|
|
|
|
// copy_multi spawns a background thread that serves clipboard requests
|
|
// until another client takes ownership — no blocking, no subprocess needed.
|
|
if let Err(e) = Options::new().copy_multi(sources) {
|
|
eprintln!("[clipboard_platform] wl-clipboard-rs set error: {e}");
|
|
}
|
|
}
|
|
|
|
fn get_wayland(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
|
|
use std::io::Read;
|
|
use wl_clipboard_rs::paste::{self, ClipboardType, Error, MimeType, Seat};
|
|
|
|
for &mime in preferred {
|
|
match paste::get_contents(
|
|
ClipboardType::Regular,
|
|
Seat::Unspecified,
|
|
MimeType::Specific(mime),
|
|
) {
|
|
Ok((mut pipe, _)) => {
|
|
let mut buf = Vec::new();
|
|
if pipe.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
|
return Some((mime.to_string(), buf));
|
|
}
|
|
}
|
|
// These are non-error "not present" conditions — try the next type.
|
|
Err(Error::ClipboardEmpty) | Err(Error::NoMimeType) => continue,
|
|
Err(e) => {
|
|
eprintln!("[clipboard_platform] wl-clipboard-rs get error for {mime}: {e}");
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// ── X11 ──────────────────────────────────────────────────────────────────
|
|
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
|
|
/// Keeps the x11-clipboard instance alive so its background thread can
|
|
/// continue serving SelectionRequest events. Replaced on each `set_x11` call.
|
|
static X11_CB: Mutex<Option<x11_clipboard::Clipboard>> = Mutex::new(None);
|
|
|
|
fn set_x11(entries: &[(&str, &[u8])]) {
|
|
// X11 clipboard can only serve one target atom per selection owner.
|
|
// Use the first entry (the custom LB MIME type).
|
|
let Some(&(mime, data)) = entries.first() else { return };
|
|
|
|
let cb = match x11_clipboard::Clipboard::new() {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("[clipboard_platform] x11-clipboard init error: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let atom = match cb.setter.get_atom(mime) {
|
|
Ok(a) => a,
|
|
Err(e) => {
|
|
eprintln!("[clipboard_platform] x11-clipboard intern atom error for {mime}: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
if let Err(e) = cb.store(cb.setter.atoms.clipboard, atom, data.to_vec()) {
|
|
eprintln!("[clipboard_platform] x11-clipboard store error: {e}");
|
|
return;
|
|
}
|
|
|
|
// Keep alive to serve requests; replacing drops the previous instance.
|
|
*X11_CB.lock().unwrap_or_else(|e| e.into_inner()) = Some(cb);
|
|
}
|
|
|
|
fn get_x11(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
|
|
let cb = match x11_clipboard::Clipboard::new() {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("[clipboard_platform] x11-clipboard init error: {e}");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
for &mime in preferred {
|
|
let atom = match cb.getter.get_atom(mime) {
|
|
Ok(a) => a,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
match cb.load(
|
|
cb.getter.atoms.clipboard,
|
|
atom,
|
|
cb.getter.atoms.property,
|
|
Duration::from_secs(1),
|
|
) {
|
|
Ok(data) if !data.is_empty() => return Some((mime.to_string(), data)),
|
|
_ => continue,
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────── Fallback (other OS) ───────────────────────────
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
|
mod platform_impl {
|
|
pub fn set(_entries: &[(&str, &[u8])]) {}
|
|
pub fn get(_preferred: &[&str]) -> Option<(String, Vec<u8>)> {
|
|
None
|
|
}
|
|
}
|