use std::sync::atomic::{AtomicI64, Ordering};
use crate::error::{Error, Result};
use crate::storage::Kind;
#[derive(Debug, Clone)]
pub struct ClipboardEvent {
pub kind: Kind,
pub bytes: Vec<u8>,
pub change_count: i64,
}
#[cfg(target_os = "macos")]
mod imp {
use super::*;
use objc2::rc::autoreleasepool;
use objc2_app_kit::{
NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString,
};
use objc2_foundation::{NSArray, NSString};
pub fn current_change_count() -> i64 {
autoreleasepool(|_| NSPasteboard::generalPasteboard().changeCount() as i64)
}
pub fn poll_once(
self_write_token: &AtomicI64,
last: i64,
) -> Result<Option<ClipboardEvent>> {
autoreleasepool(|_| {
let pb = NSPasteboard::generalPasteboard();
let cc = pb.changeCount() as i64;
if cc <= last {
return Ok(None);
}
if cc == self_write_token.load(Ordering::SeqCst) {
return Ok(None);
}
if let Some(s) = unsafe { pb.stringForType(NSPasteboardTypeString) } {
let text = s.to_string();
if !text.is_empty() {
return Ok(Some(ClipboardEvent {
kind: Kind::Text,
bytes: text.into_bytes(),
change_count: cc,
}));
}
}
if let Some(data) = unsafe { pb.dataForType(NSPasteboardTypePNG) } {
let bytes = data.to_vec();
if !bytes.is_empty() {
return Ok(Some(ClipboardEvent {
kind: Kind::Image,
bytes,
change_count: cc,
}));
}
}
Ok(Some(ClipboardEvent {
kind: Kind::Text,
bytes: Vec::new(),
change_count: cc,
}))
})
}
pub fn write_text(text: &str, self_write_token: &AtomicI64) -> Result<i64> {
autoreleasepool(|_| {
let pb = NSPasteboard::generalPasteboard();
pb.clearContents();
let ns = NSString::from_str(text);
let types = NSArray::from_slice(&[unsafe { NSPasteboardTypeString }]);
let _ = unsafe { pb.declareTypes_owner(&types, None) };
let ok = pb.setString_forType(&ns, unsafe { NSPasteboardTypeString });
if !ok {
return Err(Error::ClipboardAccess(
"NSPasteboard setString:forType: returned NO".into(),
));
}
let cc = pb.changeCount() as i64;
self_write_token.store(cc, Ordering::SeqCst);
Ok(cc)
})
}
}
#[cfg(not(target_os = "macos"))]
mod imp {
use super::*;
pub fn current_change_count() -> i64 {
0
}
pub fn poll_once(
_self_write_token: &AtomicI64,
_last: i64,
) -> Result<Option<ClipboardEvent>> {
Err(Error::ClipboardAccess("NSPasteboard requires macOS".into()))
}
pub fn write_text(_text: &str, _self_write_token: &AtomicI64) -> Result<i64> {
Err(Error::ClipboardAccess("NSPasteboard requires macOS".into()))
}
}
#[allow(unused_imports)]
pub use imp::{current_change_count, poll_once, write_text};
use std::sync::Arc;
pub trait ClipboardWriter: Send + Sync {
fn write_text(&self, s: &str) -> Result<i64>;
}
pub struct SystemClipboardWriter {
pub self_write_token: Arc<AtomicI64>,
}
impl SystemClipboardWriter {
pub fn new(self_write_token: Arc<AtomicI64>) -> Self {
Self { self_write_token }
}
}
impl ClipboardWriter for SystemClipboardWriter {
fn write_text(&self, s: &str) -> Result<i64> {
imp::write_text(s, &self.self_write_token)
}
}
#[cfg(test)]
pub struct NullClipboardWriter;
#[cfg(test)]
impl ClipboardWriter for NullClipboardWriter {
fn write_text(&self, _s: &str) -> Result<i64> {
Ok(0)
}
}
#[cfg(test)]
#[derive(Debug, Default)]
pub struct CountingClipboardWriter {
pub calls: std::sync::Mutex<Vec<String>>,
pub next_change_count: std::sync::atomic::AtomicI64,
}
#[cfg(test)]
impl CountingClipboardWriter {
pub fn new() -> Self {
Self::default()
}
pub fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
#[cfg(test)]
impl ClipboardWriter for CountingClipboardWriter {
fn write_text(&self, s: &str) -> Result<i64> {
self.calls.lock().unwrap().push(s.to_string());
Ok(self
.next_change_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "macos")]
#[test]
#[ignore = "touches the system pasteboard; run with --ignored"]
fn change_count_advances_after_write() {
let token = AtomicI64::new(0);
let before = current_change_count();
write_text("textlog test write", &token).expect("write should succeed");
let after = current_change_count();
assert!(after > before, "{after} > {before}");
assert_eq!(token.load(Ordering::SeqCst), after);
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "touches the system pasteboard; run with --ignored"]
fn poll_skips_self_write() {
let token = AtomicI64::new(0);
let last = current_change_count();
let cc = write_text("self-write skip target", &token).unwrap();
assert!(cc > last, "write must advance the change count");
let ev = poll_once(&token, last).expect("poll should not error");
assert!(ev.is_none(), "self-write must short-circuit; got {ev:?}");
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "touches the system pasteboard; run with --ignored"]
fn poll_skips_when_last_is_in_future() {
let token = AtomicI64::new(0);
let cc = current_change_count();
let ev = poll_once(&token, cc + 1_000_000).unwrap();
assert!(ev.is_none());
}
#[test]
fn clipboard_event_carries_change_count() {
let ev = ClipboardEvent {
kind: Kind::Text,
bytes: b"hi".to_vec(),
change_count: 42,
};
assert_eq!(ev.change_count, 42);
assert_eq!(ev.kind, Kind::Text);
}
}