Files
vamp/src/clipboard.rs
T
2026-10-16 18:53:02 +02:00

546 lines
18 KiB
Rust

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<ClipboardCommand>,
writer: Option<ClipboardCommand>,
}
pub struct SystemClipboard {
commands: Option<Result<Commands, String>>,
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<String, String> {
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<PathBuf> {
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<Commands, String> {
#[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<Self, String> {
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<String, String> {
// 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<String, String> {
// 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<Vec<u8>, 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<Vec<u8>, 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<std::process::Child>);
#[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;