use std::path::PathBuf;
use crate::MimeType;
pub const UTF8_PLAIN_TEXT: &str = "public.utf8-plain-text";
pub const FILE_URL: &str = "public.file-url";
#[must_use]
pub const fn pasteboard_type(mime: MimeType) -> &'static str {
match mime {
MimeType::TextUtf8 => UTF8_PLAIN_TEXT,
MimeType::CrcblRon | MimeType::UriList | MimeType::Other(_) => mime.as_str(),
}
}
#[must_use]
pub fn path_from_file_url(url: &str) -> Option<PathBuf> {
crate::clipboard::uri_list_lines(url.as_bytes())
.find_map(crate::clipboard::file_uri_to_posix_path)
}
#[cfg(target_os = "macos")]
pub(super) use system::{change_count, clear, declare, file_urls, general, get, put};
#[cfg(target_os = "macos")]
mod system {
use core::ffi::c_void;
use core::ptr;
use super::super::ffi::{self, Id, NSInteger, NSUInteger, ObjcBool, Sel};
pub(in super::super) unsafe fn general() -> Option<Id> {
let class = ffi::class(c"NSPasteboard")?;
let pasteboard = unsafe { ffi::msg(class, ffi::sel(c"generalPasteboard")) };
(!pasteboard.is_null()).then_some(pasteboard)
}
pub(in super::super) unsafe fn change_count(pasteboard: Id) -> NSInteger {
let send: unsafe extern "C" fn(Id, Sel) -> NSInteger = unsafe { ffi::msg_send() };
unsafe { send(pasteboard, ffi::sel(c"changeCount")) }
}
pub(in super::super) unsafe fn clear(pasteboard: Id) -> NSInteger {
let send: unsafe extern "C" fn(Id, Sel) -> NSInteger = unsafe { ffi::msg_send() };
unsafe { send(pasteboard, ffi::sel(c"clearContents")) }
}
pub(in super::super) unsafe fn declare(pasteboard: Id, types: &[&str]) -> Option<NSInteger> {
let array_class = ffi::class(c"NSMutableArray")?;
let array = unsafe { ffi::msg(array_class, ffi::sel(c"array")) };
if array.is_null() {
return None;
}
for name in types {
let string = unsafe { ffi::nsstring(name) }?;
unsafe { ffi::msg1_void(array, ffi::sel(c"addObject:"), string) };
}
let send: unsafe extern "C" fn(Id, Sel, Id, Id) -> NSInteger = unsafe { ffi::msg_send() };
Some(unsafe {
send(
pasteboard,
ffi::sel(c"declareTypes:owner:"),
array,
ptr::null_mut(),
)
})
}
pub(in super::super) unsafe fn put(pasteboard: Id, kind: &str, bytes: &[u8]) -> bool {
let Some(data_class) = ffi::class(c"NSData") else {
return false;
};
let (Some(kind), data) = (unsafe { ffi::nsstring(kind) }, unsafe {
let send: unsafe extern "C" fn(Id, Sel, *const c_void, NSUInteger) -> Id =
ffi::msg_send();
send(
data_class,
ffi::sel(c"dataWithBytes:length:"),
bytes.as_ptr().cast::<c_void>(),
bytes.len(),
)
}) else {
return false;
};
if data.is_null() {
return false;
}
let send: unsafe extern "C" fn(Id, Sel, Id, Id) -> ObjcBool = unsafe { ffi::msg_send() };
unsafe { send(pasteboard, ffi::sel(c"setData:forType:"), data, kind) != ffi::NO }
}
pub(in super::super) unsafe fn get(pasteboard: Id, kind: &str) -> Option<Vec<u8>> {
let kind = unsafe { ffi::nsstring(kind) }?;
let data = unsafe { ffi::msg1(pasteboard, ffi::sel(c"dataForType:"), kind) };
if data.is_null() {
return None;
}
let length = unsafe { ffi::msg_usize(data, ffi::sel(c"length")) };
if length == 0 {
return Some(Vec::new());
}
let send: unsafe extern "C" fn(Id, Sel) -> *const c_void = unsafe { ffi::msg_send() };
let pointer = unsafe { send(data, ffi::sel(c"bytes")) };
if pointer.is_null() {
return Some(Vec::new());
}
Some(unsafe { core::slice::from_raw_parts(pointer.cast::<u8>(), length) }.to_vec())
}
pub(in super::super) unsafe fn file_urls(pasteboard: Id) -> Vec<String> {
let items = unsafe { ffi::msg(pasteboard, ffi::sel(c"pasteboardItems")) };
if items.is_null() {
return Vec::new();
}
let count = unsafe { ffi::msg_usize(items, ffi::sel(c"count")) };
let Some(kind) = (
unsafe { ffi::nsstring(super::FILE_URL) }
) else {
return Vec::new();
};
let mut urls = Vec::with_capacity(count);
for index in 0..count {
let item = unsafe {
let send: unsafe extern "C" fn(Id, Sel, NSUInteger) -> Id = ffi::msg_send();
send(items, ffi::sel(c"objectAtIndex:"), index)
};
if item.is_null() {
continue;
}
let string = unsafe { ffi::msg1(item, ffi::sel(c"stringForType:"), kind) };
if let Some(url) = unsafe { ffi::string_from_nsstring(string) } {
urls.push(url);
}
}
urls
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_text_has_a_system_uti_and_every_other_mime_is_its_own_type() {
assert_eq!(
pasteboard_type(MimeType::TextUtf8),
"public.utf8-plain-text"
);
assert_eq!(
pasteboard_type(MimeType::CrcblRon),
"application/x-crcbl+ron",
"the spelling is the cross-platform contract, not an implementation detail"
);
assert_eq!(pasteboard_type(MimeType::UriList), "text/uri-list");
assert_eq!(pasteboard_type(MimeType::Other("image/png")), "image/png");
assert_eq!(UTF8_PLAIN_TEXT, "public.utf8-plain-text");
assert_eq!(FILE_URL, "public.file-url");
assert_ne!(UTF8_PLAIN_TEXT, MimeType::TextUtf8.as_str());
}
#[test]
fn a_dropped_file_url_becomes_the_path_it_names() {
assert_eq!(
path_from_file_url("file:///Users/dev/My%20Scene.ron"),
Some(PathBuf::from("/Users/dev/My Scene.ron"))
);
assert_eq!(
path_from_file_url("file:///tmp/a%2Bb%2Fc.png"),
Some(PathBuf::from("/tmp/a+b/c.png")),
"a %2F is a slash in the name, decoded after the URI was split"
);
assert_eq!(
path_from_file_url("file://localhost/tmp/x"),
Some(PathBuf::from("/tmp/x"))
);
}
#[test]
fn a_url_that_is_not_a_local_file_is_not_turned_into_a_path() {
for hostile in [
"https://example.com/x",
"file://remotehost/etc/passwd",
"file:relative/path",
"",
"not a uri at all",
] {
assert_eq!(path_from_file_url(hostile), None, "{hostile}");
}
}
}