mod provider;
use std::ffi::c_void;
use std::io;
use std::sync::mpsc as sync;
use std::thread;
use std::time::Duration;
use a7n_core::clipboard::{
IMAGE_PNG, Item, LocalChange, TEXT_HTML, TEXT_PLAIN, limit_for, validate_png,
};
use a7n_proto::limits;
use objc2::rc::{Retained, autoreleasepool};
use objc2::runtime::ProtocolObject;
use objc2_app_kit::{
NSBitmapImageFileType, NSBitmapImageRep, NSPasteboard, NSPasteboardItem, NSWorkspace,
};
use objc2_core_foundation::{
CFRunLoop, CFRunLoopTimer, CFRunLoopTimerContext, kCFRunLoopDefaultMode,
};
use objc2_foundation::{NSArray, NSData, NSDictionary, NSString, ns_string};
use tokio::sync::mpsc;
use super::{Clipboard, ClipboardCommand, PasteRequest};
const CHANNEL_CAPACITY: usize = 16;
const POLL_INTERVAL: Duration = Duration::from_millis(250);
struct State {
board: Retained<NSPasteboard>,
commands: mpsc::Receiver<ClipboardCommand>,
changes: mpsc::Sender<LocalChange>,
pastes: mpsc::Sender<PasteRequest>,
sequence: Option<isize>,
provider: Option<Retained<provider::Provider>>,
}
pub(super) fn start() -> io::Result<Clipboard> {
let (changes_tx, changes) = mpsc::channel(CHANNEL_CAPACITY);
let (commands, commands_rx) = mpsc::channel(CHANNEL_CAPACITY);
let (pastes_tx, pastes) = mpsc::channel(CHANNEL_CAPACITY);
let (ready_tx, ready) = sync::channel();
thread::Builder::new()
.name("a7n-macos-clipboard".into())
.spawn(move || {
autoreleasepool(|_| {
let mut state = State {
board: NSPasteboard::generalPasteboard(),
commands: commands_rx,
changes: changes_tx,
pastes: pastes_tx,
sequence: None,
provider: None,
};
let result = run(&mut state, &ready_tx);
if let Err(error) = result {
let _ = ready_tx.send(Err(error));
}
});
})?;
ready.recv().map_err(io::Error::other)??;
Ok(Clipboard {
images: true,
changes,
commands,
pastes,
})
}
fn run(state: &mut State, ready: &sync::Sender<io::Result<()>>) -> io::Result<()> {
let run_loop = CFRunLoop::current().ok_or_else(|| io::Error::other("missing run loop"))?;
let mut context = CFRunLoopTimerContext {
version: 0,
info: std::ptr::from_mut(state).cast(),
retain: None,
release: None,
copyDescription: None,
};
let timer = unsafe {
CFRunLoopTimer::new(
None,
0.0,
POLL_INTERVAL.as_secs_f64(),
0,
0,
Some(tick),
&raw mut context,
)
}
.ok_or_else(|| io::Error::other("cannot create clipboard timer"))?;
run_loop.add_timer(Some(&timer), unsafe { kCFRunLoopDefaultMode });
if ready.send(Ok(())).is_ok() {
CFRunLoop::run();
}
timer.invalidate();
if let Some(provider) = state.provider.take() {
provider.finish();
}
Ok(())
}
unsafe extern "C-unwind" fn tick(_: *mut CFRunLoopTimer, info: *mut c_void) {
autoreleasepool(|_| {
let state = unsafe { &mut *info.cast::<State>() };
for _ in 0..CHANNEL_CAPACITY {
match state.commands.try_recv() {
Ok(command) => state.set(command),
Err(mpsc::error::TryRecvError::Empty) => break,
Err(mpsc::error::TryRecvError::Disconnected) => {
if let Some(run_loop) = CFRunLoop::current() {
run_loop.stop();
}
return;
}
}
}
state.changed();
});
}
impl State {
fn changed(&mut self) {
let sequence = self.board.changeCount();
if self.sequence == Some(sequence) {
return;
}
if let Some(provider) = self.provider.take() {
provider.finish();
}
let Some(types) = self.board.types() else {
self.sequence = Some(sequence);
return;
};
let sensitive = types.containsObject(ns_string!("org.nspasteboard.ConcealedType"))
|| types.containsObject(ns_string!("org.nspasteboard.TransientType"));
let mut change = LocalChange {
items: Vec::new(),
sensitive,
source_app: NSWorkspace::sharedWorkspace()
.frontmostApplication()
.and_then(|app| app.bundleIdentifier())
.map(|id| id.to_string()),
};
if !sensitive {
for (mime, uti) in formats() {
if let Some(data) = self.board.dataForType(uti) {
if data.length() <= limit_for(mime) {
change.items.push(Item {
mime: mime.into(),
data: data.to_vec(),
});
}
}
}
if !change.items.iter().any(|item| item.mime == IMAGE_PNG) {
if let Some(data) = self.board.dataForType(ns_string!("public.tiff")) {
if let Some(png) = convert_image(&data, NSBitmapImageFileType::PNG) {
if png.length() <= limits::IMAGE_CLIPBOARD_MAX {
change.items.push(Item {
mime: IMAGE_PNG.into(),
data: png.to_vec(),
});
}
}
}
}
}
if self.board.changeCount() == sequence && self.changes.try_send(change).is_ok() {
self.sequence = Some(sequence);
}
}
fn set(&mut self, command: ClipboardCommand) {
let ClipboardCommand::Set {
generation,
items,
lazy,
} = command;
let item = NSPasteboardItem::new();
for entry in items {
if entry.data.len() <= limit_for(&entry.mime) {
if let Some(uti) = uti(&entry.mime) {
let data = NSData::with_bytes(&entry.data);
if entry.mime == IMAGE_PNG {
if validate_png(&entry.data).is_err() {
continue;
}
if let Some(tiff) = convert_image(&data, NSBitmapImageFileType::TIFF) {
item.setData_forType(&tiff, ns_string!("public.tiff"));
}
}
item.setData_forType(&data, uti);
}
}
}
let eager = item.types();
let mut offered = Vec::new();
let lazy: Vec<_> = lazy
.into_iter()
.filter(|entry| {
let Some(uti) = uti(&entry.mime) else {
return false;
};
if entry.size > limit_for(&entry.mime) as u64
|| eager.containsObject(uti)
|| offered.contains(&uti)
{
return false;
}
offered.push(uti);
if entry.mime == IMAGE_PNG {
offered.push(ns_string!("public.tiff"));
}
true
})
.collect();
let provider = provider::Provider::new(generation, lazy, self.pastes.clone());
if !offered.is_empty() {
item.setDataProvider_forTypes(
ProtocolObject::from_ref(&*provider),
&NSArray::from_slice(&offered),
);
}
if let Some(old) = self.provider.take() {
old.finish();
}
self.sequence = Some(self.board.clearContents());
if self
.board
.writeObjects(&NSArray::from_slice(&[ProtocolObject::from_ref(&*item)]))
{
self.provider = Some(provider);
}
}
}
fn formats() -> [(&'static str, &'static NSString); 3] {
[
(TEXT_PLAIN, ns_string!("public.utf8-plain-text")),
(TEXT_HTML, ns_string!("public.html")),
(IMAGE_PNG, ns_string!("public.png")),
]
}
fn uti(mime: &str) -> Option<&'static NSString> {
formats()
.into_iter()
.find_map(|(supported, uti)| (mime == supported).then_some(uti))
}
fn convert_image(data: &NSData, kind: NSBitmapImageFileType) -> Option<Retained<NSData>> {
if data.length() > limits::IMAGE_DECODED_MAX {
return None;
}
let image = NSBitmapImageRep::imageRepWithData(data)?;
let width = u64::try_from(image.pixelsWide()).ok()?;
let height = u64::try_from(image.pixelsHigh()).ok()?;
if width == 0
|| height == 0
|| width > u64::from(limits::IMAGE_MAX_DIMENSION)
|| height > u64::from(limits::IMAGE_MAX_DIMENSION)
|| width * height * 4 > limits::IMAGE_DECODED_MAX as u64
{
return None;
}
unsafe { image.representationUsingType_properties(kind, &NSDictionary::new()) }
}