use std::path::PathBuf; use std::time::Duration; const MAX_CLIPBOARD_BYTES: usize = 4 * 1024 * 1024; const COMMAND_TIMEOUT: Duration = Duration::from_secs(2); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClipboardMode { System, Separate, } struct ClipboardCommand { program: PathBuf, args: &'static [&'static str], } struct Commands { reader: Option, writer: Option, } pub struct SystemClipboard { commands: Option>, timeout: Duration, } impl Default for SystemClipboard { fn default() -> Self { Self::new() } } impl SystemClipboard { pub fn new() -> Self { Self { commands: None, timeout: COMMAND_TIMEOUT, } } #[cfg(test)] pub(crate) fn with_commands(reader: PathBuf, writer: PathBuf) -> Self { Self { commands: Some(Ok(Commands { reader: Some(ClipboardCommand { program: reader, args: &[], }), writer: Some(ClipboardCommand { program: writer, args: &[], }), })), timeout: COMMAND_TIMEOUT, } } pub fn read(&mut self) -> Result { let timeout = self.timeout; let commands = self.commands.get_or_insert_with(select_commands); match &commands.as_ref().map_err(Clone::clone)?.reader { Some(command) => { let bytes = run(command, None, timeout)?; String::from_utf8(bytes).map_err(|_| "System clipboard is not valid UTF-8".into()) } #[cfg(target_os = "macos")] None => macos::read(), #[cfg(not(target_os = "macos"))] None => Err("System clipboard is unsupported on this platform".into()), } } pub fn write(&mut self, text: &str) -> Result<(), String> { if text.len() > MAX_CLIPBOARD_BYTES { return Err("System clipboard text exceeds 4 MiB".into()); } let timeout = self.timeout; let commands = self.commands.get_or_insert_with(select_commands); match &commands.as_ref().map_err(Clone::clone)?.writer { Some(command) => run(command, Some(text.as_bytes()), timeout).map(|_| ()), #[cfg(target_os = "macos")] None => macos::write(text), #[cfg(not(target_os = "macos"))] None => Err("System clipboard is unsupported on this platform".into()), } } } #[cfg(target_os = "linux")] fn executable(name: &str) -> Option { use std::os::unix::fs::PermissionsExt; let paths = std::env::var_os("PATH")?; std::env::split_paths(&paths) .map(|directory| directory.join(name)) .find(|path| { path.metadata().is_ok_and(|metadata| { metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 }) }) } fn select_commands() -> Result { #[cfg(target_os = "macos")] { Ok(Commands { reader: None, writer: None, }) } #[cfg(target_os = "linux")] { if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|value| !value.is_empty()) { if let (Some(reader), Some(writer)) = (executable("wl-paste"), executable("wl-copy")) { return Ok(Commands { reader: Some(ClipboardCommand { program: reader, args: &["--no-newline", "--type", "text"], }), writer: Some(ClipboardCommand { program: writer, args: &["--type", "text/plain;charset=utf-8"], }), }); } } if std::env::var_os("DISPLAY").is_some_and(|value| !value.is_empty()) { if let Some(program) = executable("xclip") { return Ok(Commands { reader: Some(ClipboardCommand { program: program.clone(), args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"], }), writer: Some(ClipboardCommand { program, args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"], }), }); } } Err("System clipboard requires Wayland with wl-paste/wl-copy or X11 with xclip".into()) } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { Err("System clipboard is unsupported on this platform".into()) } } #[cfg(target_os = "macos")] mod macos { use std::ffi::{c_char, c_void}; use std::ptr; type Object = *const c_void; #[link(name = "CoreFoundation", kind = "framework")] unsafe extern "C" { fn CFStringCreateWithCString( allocator: Object, bytes: *const c_char, encoding: u32, ) -> Object; fn CFDataCreate(allocator: Object, bytes: *const u8, length: isize) -> Object; fn CFRelease(object: Object); } #[link(name = "CoreServices", kind = "framework")] unsafe extern "C" { static kUTTypeUTF8PlainText: Object; } #[link(name = "ApplicationServices", kind = "framework")] unsafe extern "C" { fn PasteboardCreate(name: Object, pasteboard: *mut Object) -> i32; fn PasteboardClear(pasteboard: Object) -> i32; fn PasteboardPutItemFlavor( pasteboard: Object, item: usize, flavor: Object, data: Object, flags: u32, ) -> i32; } struct Owned(Object); impl Owned { fn new(object: Object) -> Result { if object.is_null() { Err("Cannot allocate system clipboard data".into()) } else { Ok(Self(object)) } } } impl Drop for Owned { fn drop(&mut self) { // SAFETY: each handle owns one Core Foundation reference. unsafe { CFRelease(self.0) }; } } fn check(status: i32) -> Result<(), String> { if status == 0 { Ok(()) } else { Err(format!("System clipboard publication failed ({status})")) } } fn write_to(name: Object, text: &str) -> Result<(), String> { // SAFETY: framework constants and byte slices live throughout the calls. unsafe { let data = Owned::new(CFDataCreate( ptr::null(), text.as_ptr(), text.len() as isize, ))?; let mut board = ptr::null(); check(PasteboardCreate(name, &mut board))?; let board = Owned::new(board)?; // Prepare all allocations before changing clipboard contents. check(PasteboardClear(board.0))?; check(PasteboardPutItemFlavor( board.0, 1, kUTTypeUTF8PlainText, data.0, 0, )) } } pub(super) fn write(text: &str) -> Result<(), String> { // SAFETY: the clipboard name is a process-lifetime, null-terminated literal. let name = unsafe { Owned::new(CFStringCreateWithCString( ptr::null(), c"com.apple.pasteboard.clipboard".as_ptr(), 0x0800_0100, ))? }; write_to(name.0, text) } #[link(name = "AppKit", kind = "framework")] unsafe extern "C" { static NSPasteboardTypeString: Object; } #[link(name = "objc")] unsafe extern "C" { fn objc_getClass(name: *const c_char) -> Object; fn sel_registerName(name: *const c_char) -> Object; fn objc_msgSend(); fn objc_autoreleasePoolPush() -> Object; fn objc_autoreleasePoolPop(pool: Object); } struct AutoreleasePool(Object); impl Drop for AutoreleasePool { fn drop(&mut self) { // SAFETY: this pool was pushed on the same thread. unsafe { objc_autoreleasePoolPop(self.0) }; } } fn read_from(board: Object) -> Result { // SAFETY: receivers are live AppKit objects; each cast matches the // selected Objective-C method's ABI. The caller owns an autorelease pool. unsafe { let string: unsafe extern "C" fn(Object, Object, Object) -> Object = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); let text = string( board, sel_registerName(c"stringForType:".as_ptr()), NSPasteboardTypeString, ); if text.is_null() { return Ok(String::new()); } let encode: unsafe extern "C" fn(Object, Object, usize) -> Object = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); let data = encode(text, sel_registerName(c"dataUsingEncoding:".as_ptr()), 4); if data.is_null() { return Err("System clipboard is not valid UTF-8".into()); } let length: unsafe extern "C" fn(Object, Object) -> usize = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); let length = length(data, sel_registerName(c"length".as_ptr())); if length > super::MAX_CLIPBOARD_BYTES { return Err("System clipboard text exceeds 4 MiB".into()); } if length == 0 { return Ok(String::new()); } let bytes: unsafe extern "C" fn(Object, Object) -> *const u8 = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); let bytes = std::slice::from_raw_parts( bytes(data, sel_registerName(c"bytes".as_ptr())), length, ); String::from_utf8(bytes.to_vec()) .map_err(|_| "System clipboard is not valid UTF-8".into()) } } pub(super) fn read() -> Result { // SAFETY: AppKit class and selector names are static C strings. unsafe { let _pool = AutoreleasePool(objc_autoreleasePoolPush()); let send: unsafe extern "C" fn(Object, Object) -> Object = std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); let board = send( objc_getClass(c"NSPasteboard".as_ptr()), sel_registerName(c"generalPasteboard".as_ptr()), ); if board.is_null() { return Err("Cannot open system clipboard".into()); } read_from(board) } } #[cfg(test)] mod tests { include!("../tests/unit/clipboard_macos.rs"); } } #[cfg(not(any(target_os = "macos", target_os = "linux")))] fn run(_: &ClipboardCommand, _: Option<&[u8]>, _: Duration) -> Result, String> { Err("System clipboard is unsupported on this platform".into()) } #[cfg(any(target_os = "macos", target_os = "linux"))] fn run( command: &ClipboardCommand, input: Option<&[u8]>, timeout: Duration, ) -> Result, String> { use std::io::{self, Read, Write}; use std::os::unix::process::CommandExt; use std::process::{Command, Stdio}; use std::time::Instant; let mut command_process = Command::new(&command.program); command_process .args(command.args) .process_group(0) .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) .stdout(if input.is_none() { Stdio::piped() } else { Stdio::null() }) // Native tools may echo input in diagnostics. Never capture or surface stderr. .stderr(Stdio::null()); let child = command_process .spawn() .map_err(|error| format!("Cannot start system clipboard command: {error}"))?; let mut process = ClipboardProcess(Some(child)); let child = process.0.as_mut().expect("clipboard child exists"); let mut stdin = child.stdin.take(); let mut stdout = child.stdout.take(); if let Some(pipe) = &stdin { nonblocking(pipe).map_err(|error| format!("Cannot open clipboard input: {error}"))?; } if let Some(pipe) = &stdout { nonblocking(pipe).map_err(|error| format!("Cannot open clipboard output: {error}"))?; } let started = Instant::now(); let mut written = 0; let mut output = Vec::new(); let mut status = None; let mut chunk = [0_u8; 8192]; loop { if started.elapsed() >= timeout { return Err("System clipboard command timed out".into()); } let mut progressed = false; if let Some(pipe) = stdin.as_mut() { let bytes = input.expect("input pipe has bytes"); if written == bytes.len() { stdin.take(); progressed = true; } else { let end = bytes.len().min(written + chunk.len()); match pipe.write(&bytes[written..end]) { Ok(0) => return Err("System clipboard command closed its input".into()), Ok(count) => { written += count; progressed = true; } Err(error) if matches!( error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted ) => {} Err(error) => return Err(format!("System clipboard write failed: {error}")), } } } if let Some(pipe) = stdout.as_mut() { let limit = chunk.len().min(MAX_CLIPBOARD_BYTES + 1 - output.len()); match pipe.read(&mut chunk[..limit]) { Ok(0) => { stdout.take(); progressed = true; } Ok(count) => { if output.len() + count > MAX_CLIPBOARD_BYTES { return Err("System clipboard text exceeds 4 MiB".into()); } output.extend_from_slice(&chunk[..count]); progressed = true; } Err(error) if matches!( error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted ) => {} Err(error) => return Err(format!("System clipboard read failed: {error}")), } } if status.is_none() { status = process .0 .as_mut() .expect("clipboard child exists") .try_wait() .map_err(|error| format!("Cannot wait for system clipboard command: {error}"))?; } if let Some(exit) = status { if !exit.success() { return Err(format!("System clipboard command failed ({exit})")); } if stdin.is_none() && stdout.is_none() { // Clipboard owners (wl-copy/xclip) intentionally leave a serving child alive. process.0.take(); return Ok(output); } } if !progressed { let remaining = timeout.saturating_sub(started.elapsed()); wait_for_pipes(stdin.as_ref(), stdout.as_ref(), remaining)?; } } } #[cfg(any(target_os = "macos", target_os = "linux"))] struct ClipboardProcess(Option); #[cfg(any(target_os = "macos", target_os = "linux"))] impl Drop for ClipboardProcess { fn drop(&mut self) { if let Some(mut child) = self.0.take() { // Also close inherited pipes held by descendants, as in the Ruby host runner. // SAFETY: spawn placed this child in a process group with its own PID. unsafe { kill(-(child.id() as i32), 9); } let _ = child.kill(); let _ = child.wait(); } } } #[cfg(any(target_os = "macos", target_os = "linux"))] fn nonblocking(pipe: &impl std::os::fd::AsRawFd) -> std::io::Result<()> { #[cfg(target_os = "macos")] const O_NONBLOCK: i32 = 0x0004; #[cfg(target_os = "linux")] const O_NONBLOCK: i32 = 0x0800; // SAFETY: the descriptor is borrowed from a live child pipe; F_GETFL/F_SETFL // inspect/change flags only and neither consume the descriptor nor access pointers. unsafe { let flags = fcntl(pipe.as_raw_fd(), 3); if flags == -1 || fcntl(pipe.as_raw_fd(), 4, flags | O_NONBLOCK) == -1 { Err(std::io::Error::last_os_error()) } else { Ok(()) } } } #[cfg(any(target_os = "macos", target_os = "linux"))] #[repr(C)] struct PollFd { fd: i32, events: i16, revents: i16, } #[cfg(target_os = "macos")] type PollCount = u32; #[cfg(target_os = "linux")] type PollCount = usize; #[cfg(any(target_os = "macos", target_os = "linux"))] fn wait_for_pipes( stdin: Option<&std::process::ChildStdin>, stdout: Option<&std::process::ChildStdout>, remaining: Duration, ) -> Result<(), String> { use std::os::fd::AsRawFd; let mut pipes = [ PollFd { fd: stdin.map_or(-1, AsRawFd::as_raw_fd), events: 4, revents: 0, }, PollFd { fd: stdout.map_or(-1, AsRawFd::as_raw_fd), events: 1, revents: 0, }, ]; // Recheck process completion at least every 5 ms, waking immediately on pipe readiness. let milliseconds = remaining.as_millis().min(5) as i32; // SAFETY: the C-compatible array is live for the call; negative descriptors are ignored. if unsafe { poll(pipes.as_mut_ptr(), pipes.len() as PollCount, milliseconds) } == -1 { let error = std::io::Error::last_os_error(); if error.kind() != std::io::ErrorKind::Interrupted { return Err(format!("Cannot poll system clipboard command: {error}")); } } Ok(()) } #[cfg(any(target_os = "macos", target_os = "linux"))] unsafe extern "C" { fn fcntl(fd: i32, command: i32, ...) -> i32; fn kill(pid: i32, signal: i32) -> i32; fn poll(fds: *mut PollFd, count: PollCount, timeout: i32) -> i32; } #[cfg(all(test, any(target_os = "macos", target_os = "linux")))] #[path = "../tests/unit/clipboard.rs"] mod tests;