From 6b27fab075a626c9a55a34fe0e503aea62d51549 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Wed, 14 Oct 2026 14:23:58 +0200 Subject: [PATCH] fix: read macOS clipboard without subprocess startup --- docs/editing.md | 2 +- src/clipboard.rs | 143 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 118 insertions(+), 27 deletions(-) diff --git a/docs/editing.md b/docs/editing.md index cdae173..b966be2 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -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 diff --git a/src/clipboard.rs b/src/clipboard.rs index a0501fb..07ff087 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -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, writer: Option, } @@ -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 { 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 { use std::os::unix::fs::PermissionsExt; @@ -109,10 +105,7 @@ fn select_commands() -> Result { #[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 { 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 { 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 { + // 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 { 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" + ); } } }