fix: read macOS clipboard without subprocess startup
This commit is contained in:
+1
-1
@@ -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.
|
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
|
## Repeat last edit
|
||||||
|
|
||||||
|
|||||||
+117
-26
@@ -4,17 +4,6 @@ use std::time::Duration;
|
|||||||
const MAX_CLIPBOARD_BYTES: usize = 4 * 1024 * 1024;
|
const MAX_CLIPBOARD_BYTES: usize = 4 * 1024 * 1024;
|
||||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum ClipboardMode {
|
pub enum ClipboardMode {
|
||||||
System,
|
System,
|
||||||
@@ -27,7 +16,7 @@ struct ClipboardCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct Commands {
|
struct Commands {
|
||||||
reader: ClipboardCommand,
|
reader: Option<ClipboardCommand>,
|
||||||
writer: Option<ClipboardCommand>,
|
writer: Option<ClipboardCommand>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +43,10 @@ impl SystemClipboard {
|
|||||||
pub(crate) fn with_commands(reader: PathBuf, writer: PathBuf) -> Self {
|
pub(crate) fn with_commands(reader: PathBuf, writer: PathBuf) -> Self {
|
||||||
Self {
|
Self {
|
||||||
commands: Some(Ok(Commands {
|
commands: Some(Ok(Commands {
|
||||||
reader: ClipboardCommand {
|
reader: Some(ClipboardCommand {
|
||||||
program: reader,
|
program: reader,
|
||||||
args: &[],
|
args: &[],
|
||||||
},
|
}),
|
||||||
writer: Some(ClipboardCommand {
|
writer: Some(ClipboardCommand {
|
||||||
program: writer,
|
program: writer,
|
||||||
args: &[],
|
args: &[],
|
||||||
@@ -70,9 +59,16 @@ impl SystemClipboard {
|
|||||||
pub fn read(&mut self) -> Result<String, String> {
|
pub fn read(&mut self) -> Result<String, String> {
|
||||||
let timeout = self.timeout;
|
let timeout = self.timeout;
|
||||||
let commands = self.commands.get_or_insert_with(select_commands);
|
let commands = self.commands.get_or_insert_with(select_commands);
|
||||||
let command = &commands.as_ref().map_err(Clone::clone)?.reader;
|
match &commands.as_ref().map_err(Clone::clone)?.reader {
|
||||||
let bytes = run(command, None, timeout)?;
|
Some(command) => {
|
||||||
String::from_utf8(bytes).map_err(|_| "System clipboard is not valid UTF-8".into())
|
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> {
|
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> {
|
fn executable(name: &str) -> Option<PathBuf> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
@@ -109,10 +105,7 @@ fn select_commands() -> Result<Commands, String> {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
Ok(Commands {
|
Ok(Commands {
|
||||||
reader: ClipboardCommand {
|
reader: None,
|
||||||
program: executable("osascript").unwrap_or_else(|| "/usr/bin/osascript".into()),
|
|
||||||
args: &["-l", "JavaScript", "-e", MAC_READ],
|
|
||||||
},
|
|
||||||
writer: 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 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")) {
|
if let (Some(reader), Some(writer)) = (executable("wl-paste"), executable("wl-copy")) {
|
||||||
return Ok(Commands {
|
return Ok(Commands {
|
||||||
reader: ClipboardCommand {
|
reader: Some(ClipboardCommand {
|
||||||
program: reader,
|
program: reader,
|
||||||
args: &["--no-newline", "--type", "text"],
|
args: &["--no-newline", "--type", "text"],
|
||||||
},
|
}),
|
||||||
writer: Some(ClipboardCommand {
|
writer: Some(ClipboardCommand {
|
||||||
program: writer,
|
program: writer,
|
||||||
args: &["--type", "text/plain;charset=utf-8"],
|
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 std::env::var_os("DISPLAY").is_some_and(|value| !value.is_empty()) {
|
||||||
if let Some(program) = executable("xclip") {
|
if let Some(program) = executable("xclip") {
|
||||||
return Ok(Commands {
|
return Ok(Commands {
|
||||||
reader: ClipboardCommand {
|
reader: Some(ClipboardCommand {
|
||||||
program: program.clone(),
|
program: program.clone(),
|
||||||
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"],
|
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"],
|
||||||
},
|
}),
|
||||||
writer: Some(ClipboardCommand {
|
writer: Some(ClipboardCommand {
|
||||||
program,
|
program,
|
||||||
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"],
|
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"],
|
||||||
@@ -250,6 +243,85 @@ mod macos {
|
|||||||
write_to(name.0, text)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -272,6 +344,7 @@ mod macos {
|
|||||||
#[test]
|
#[test]
|
||||||
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
|
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
|
||||||
// A private pasteboard leaves the user's clipboard untouched.
|
// A private pasteboard leaves the user's clipboard untouched.
|
||||||
|
let _pool = unsafe { AutoreleasePool(objc_autoreleasePoolPush()) };
|
||||||
let name = unsafe {
|
let name = unsafe {
|
||||||
Owned::new(CFStringCreateWithCString(
|
Owned::new(CFStringCreateWithCString(
|
||||||
ptr::null(),
|
ptr::null(),
|
||||||
@@ -285,6 +358,15 @@ mod macos {
|
|||||||
check(PasteboardCreate(name.0, &mut board)).unwrap();
|
check(PasteboardCreate(name.0, &mut board)).unwrap();
|
||||||
Owned::new(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 [
|
for text in [
|
||||||
"",
|
"",
|
||||||
"é e\u{301} 👨💻\n\0tail",
|
"é e\u{301} 👨💻\n\0tail",
|
||||||
@@ -292,6 +374,7 @@ mod macos {
|
|||||||
"%!PS-Adobe-3.0\nplain",
|
"%!PS-Adobe-3.0\nplain",
|
||||||
] {
|
] {
|
||||||
write_to(name.0, text).unwrap();
|
write_to(name.0, text).unwrap();
|
||||||
|
assert_eq!(read_from(reader).unwrap(), text);
|
||||||
unsafe {
|
unsafe {
|
||||||
PasteboardSynchronize(board.0);
|
PasteboardSynchronize(board.0);
|
||||||
let mut item = 0;
|
let mut item = 0;
|
||||||
@@ -317,6 +400,14 @@ mod macos {
|
|||||||
assert_eq!(bytes, text.as_bytes());
|
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