fix: read macOS clipboard without subprocess startup
This commit is contained in:
+117
-26
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user