fix: read macOS clipboard without subprocess startup

This commit is contained in:
Valdemar Lorenzen
2026-10-14 14:23:58 +02:00
parent 0000089f42
commit 6b27fab075
2 changed files with 118 additions and 27 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ For other applications, characterwise multi-selection exports join fragments wit
System paste validates UTF-8, unsupported controls, newlines, and a 4 MiB limit before editing; CRLF/CR normalize to LF. System exports also have a 4 MiB limit. Provider failures report an error without deleting/changing buffer text, caret/selection state, history, or repeat intent. No silent internal fallback occurs in System mode; choose Separate if clipboard access is unavailable. A native provider that fails after publishing may already have changed OS clipboard formats; vamp cannot roll that external side effect back.
macOS copies publish UTF-8 plain text directly through the native pasteboard API, avoiding interpreter/process startup for every copy or cut; paste reads still use `osascript` with JXA/AppKit. Linux Wayland uses `wl-paste`/`wl-copy`, and Linux X11 uses `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable.
macOS copies publish UTF-8 plain text through the native pasteboard API, and pastes read text directly through AppKit, avoiding interpreter/process startup for every copy, cut, or paste. Linux Wayland uses `wl-paste`/`wl-copy`, and Linux X11 uses `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable.
## Repeat last edit
+117 -26
View File
@@ -4,17 +4,6 @@ use std::time::Duration;
const MAX_CLIPBOARD_BYTES: usize = 4 * 1024 * 1024;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(target_os = "macos")]
const MAC_READ: &str = r#"
ObjC.import('AppKit');
function run() {
var text = $.NSPasteboard.generalPasteboard.stringForType($.NSPasteboardTypeString);
if (text.isNil()) return;
$.NSFileHandle.fileHandleWithStandardOutput.writeData(
text.dataUsingEncoding($.NSUTF8StringEncoding));
}
"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClipboardMode {
System,
@@ -27,7 +16,7 @@ struct ClipboardCommand {
}
struct Commands {
reader: ClipboardCommand,
reader: Option<ClipboardCommand>,
writer: Option<ClipboardCommand>,
}
@@ -54,10 +43,10 @@ impl SystemClipboard {
pub(crate) fn with_commands(reader: PathBuf, writer: PathBuf) -> Self {
Self {
commands: Some(Ok(Commands {
reader: ClipboardCommand {
reader: Some(ClipboardCommand {
program: reader,
args: &[],
},
}),
writer: Some(ClipboardCommand {
program: writer,
args: &[],
@@ -70,9 +59,16 @@ impl SystemClipboard {
pub fn read(&mut self) -> Result<String, String> {
let timeout = self.timeout;
let commands = self.commands.get_or_insert_with(select_commands);
let command = &commands.as_ref().map_err(Clone::clone)?.reader;
let bytes = run(command, None, timeout)?;
String::from_utf8(bytes).map_err(|_| "System clipboard is not valid UTF-8".into())
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> {
@@ -91,7 +87,7 @@ impl SystemClipboard {
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[cfg(target_os = "linux")]
fn executable(name: &str) -> Option<PathBuf> {
use std::os::unix::fs::PermissionsExt;
@@ -109,10 +105,7 @@ fn select_commands() -> Result<Commands, String> {
#[cfg(target_os = "macos")]
{
Ok(Commands {
reader: ClipboardCommand {
program: executable("osascript").unwrap_or_else(|| "/usr/bin/osascript".into()),
args: &["-l", "JavaScript", "-e", MAC_READ],
},
reader: None,
writer: None,
})
}
@@ -121,10 +114,10 @@ fn select_commands() -> Result<Commands, String> {
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: ClipboardCommand {
reader: Some(ClipboardCommand {
program: reader,
args: &["--no-newline", "--type", "text"],
},
}),
writer: Some(ClipboardCommand {
program: writer,
args: &["--type", "text/plain;charset=utf-8"],
@@ -135,10 +128,10 @@ fn select_commands() -> Result<Commands, String> {
if std::env::var_os("DISPLAY").is_some_and(|value| !value.is_empty()) {
if let Some(program) = executable("xclip") {
return Ok(Commands {
reader: ClipboardCommand {
reader: Some(ClipboardCommand {
program: program.clone(),
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"],
},
}),
writer: Some(ClipboardCommand {
program,
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"],
@@ -250,6 +243,85 @@ mod macos {
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 {
use super::*;
@@ -272,6 +344,7 @@ mod macos {
#[test]
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
// A private pasteboard leaves the user's clipboard untouched.
let _pool = unsafe { AutoreleasePool(objc_autoreleasePoolPush()) };
let name = unsafe {
Owned::new(CFStringCreateWithCString(
ptr::null(),
@@ -285,6 +358,15 @@ mod macos {
check(PasteboardCreate(name.0, &mut board)).unwrap();
Owned::new(board).unwrap()
};
let reader = unsafe {
let send: unsafe extern "C" fn(Object, Object, Object) -> Object =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
send(
objc_getClass(c"NSPasteboard".as_ptr()),
sel_registerName(c"pasteboardWithName:".as_ptr()),
name.0,
)
};
for text in [
"",
"é e\u{301} 👨‍💻\n\0tail",
@@ -292,6 +374,7 @@ mod macos {
"%!PS-Adobe-3.0\nplain",
] {
write_to(name.0, text).unwrap();
assert_eq!(read_from(reader).unwrap(), text);
unsafe {
PasteboardSynchronize(board.0);
let mut item = 0;
@@ -317,6 +400,14 @@ mod macos {
assert_eq!(bytes, text.as_bytes());
}
}
let maximum = "x".repeat(super::super::MAX_CLIPBOARD_BYTES);
write_to(name.0, &maximum).unwrap();
assert_eq!(read_from(reader).unwrap(), maximum);
write_to(name.0, &(maximum + "x")).unwrap();
assert_eq!(
read_from(reader).unwrap_err(),
"System clipboard text exceeds 4 MiB"
);
}
}
}