From b9dec006c038304dcdfaa441feb86a92361f693f Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 1 Oct 2026 17:53:36 -0700 Subject: [PATCH] chore: macOS talks to the pasteboard without arboard macos.rs already read pictures from NSPasteboard itself while arboard wrapped the same pasteboard for text, pages and files, and pulled in objc2-app-kit 0.3 beside the 0.2 the app uses. The other reads and the write now go to NSPasteboard directly, as arboard made them, and arboard stays for Windows and X11. A test round-trips text, a page and files through a private pasteboard, leaving the user's alone. Assisted-by: claude-opus-5.5 --- crates/snowbound/Cargo.toml | 3 +- crates/snowbound/src/macos.rs | 102 +++++++++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 15 deletions(-) diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 1e3fa9fd109539a188384da44f0273703757fd6b..d576e08385d25ed14d32d4f64adc831d09db7baa 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -35,7 +35,6 @@ web-time = "1.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] notebook = { path = "../notebook", features = ["smb"] } -arboard = { version = "3.6.1", default-features = false } accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } # Updates: fetched over HTTPS, verified with ed25519 and SHA-256. The browser updates by # reloading. @@ -61,7 +60,7 @@ objc2-foundation = { version = "0.2.2", default-features = false, features = ["N block2 = "0.5.1" [target.'cfg(not(any(target_os = "macos", target_arch = "wasm32")))'.dependencies] -# Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself. +# macOS reads and writes the pasteboard itself. arboard = { version = "3.6.1", default-features = false, features = ["image-data"] } [target.'cfg(windows)'.dependencies] diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index dbc5d172edc68060847a36279c237eb784860562..ba221d1edf36466fda588dd4e6ae4d1eb47738b5 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -1,7 +1,7 @@ use crate::commands; use canvas::date::DateField; use objc2::{ - ClassType, DeclaredClass, + ClassType, DeclaredClass, class, declare::ClassBuilder, declare_class, msg_send, msg_send_id, mutability, rc::{Allocated, Retained}, @@ -176,36 +176,63 @@ pub fn traffic_lights(window: &Window) -> [[f32; 2]; 3] { } } -pub struct Clipboard(arboard::Clipboard); +/// The general pasteboard. +pub struct Clipboard(Retained); impl Clipboard { - pub fn new(_: &Window) -> Result { - arboard::Clipboard::new().map(Self) + pub fn new(_: &Window) -> Result { + let board: Option> = + unsafe { msg_send_id![class!(NSPasteboard), generalPasteboard] }; + board.map(Self).ok_or("No pasteboard is available") } - pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { - self.0.set_text(text) + pub fn set_text(&mut self, text: String) -> Result<(), &'static str> { + let set: bool = unsafe { + let _: isize = msg_send![&self.0, clearContents]; + let (text, kind) = (NSString::from_str(&text), NSString::from_str(TEXT)); + msg_send![&self.0, setString: &*text, forType: &*kind] + }; + set.then_some(()).ok_or("The pasteboard refused the text") } - pub fn get_text(&mut self) -> Result { - self.0.get_text() + pub fn get_text(&mut self) -> Result { + self.string(TEXT).ok_or("The pasteboard holds no text") } + /// The files copied, as Finder puts them on the pasteboard. pub fn get_files(&mut self) -> Vec { - self.0.get().file_list().unwrap_or_default() + objc2::rc::autoreleasepool(|_| unsafe { + let url: *const AnyObject = (class!(NSURL) as *const AnyClass).cast(); + let classes: Retained = msg_send_id![class!(NSArray), arrayWithObject: url]; + let yes: Retained = msg_send_id![class!(NSNumber), numberWithBool: true]; + let key = NSString::from_str("NSPasteboardURLReadingFileURLsOnlyKey"); + let options: Retained = + msg_send_id![class!(NSDictionary), dictionaryWithObject: &*yes, forKey: &*key]; + let urls: Option> = + msg_send_id![&self.0, readObjectsForClasses: &*classes, options: &*options]; + let Some(urls) = urls else { + return Vec::new(); + }; + let count: usize = msg_send![&urls, count]; + (0..count) + .filter_map(|index| { + let url: Retained = msg_send_id![&urls, objectAtIndex: index]; + let path: Option> = msg_send_id![&url, path]; + Some(path?.to_string().into()) + }) + .collect() + }) } pub fn get_html(&mut self) -> Option { - self.0.get().html().ok() + self.string("public.html") } /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG. pub fn get_picture(&mut self) -> Option> { unsafe { - let board: Retained = - msg_send_id![AnyClass::get("NSPasteboard")?, generalPasteboard]; let data = |kind: &str| -> Option> { - msg_send_id![&board, dataForType: &*NSString::from_str(kind)] + msg_send_id![&self.0, dataForType: &*NSString::from_str(kind)] }; let png = match data("public.png") { Some(png) => png, @@ -214,8 +241,27 @@ impl Clipboard { Some(ns_data_bytes(&png)) } } + + /// The first item's string of `kind`, where the pasteboard's own `stringForType:` joins + /// every item's. + fn string(&self, kind: &str) -> Option { + objc2::rc::autoreleasepool(|_| unsafe { + let items: Option> = msg_send_id![&self.0, pasteboardItems]; + let items = items?; + let count: usize = msg_send![&items, count]; + let kind = NSString::from_str(kind); + (0..count).find_map(|index| { + let item: Retained = msg_send_id![&items, objectAtIndex: index]; + let string: Option> = msg_send_id![&item, stringForType: &*kind]; + Some(string?.to_string()) + }) + }) + } } +/// NSPasteboardTypeString. +const TEXT: &str = "public.utf8-plain-text"; + /// TIFF `data` as PNG data, keeping its resolution. unsafe fn tiff_png(data: &AnyObject) -> Option> { const PNG: usize = 4; // NSBitmapImageFileTypePNG @@ -1492,8 +1538,38 @@ pub fn input_language() -> String { #[cfg(test)] mod tests { + use super::*; use crate::library::{Login, Mount}; + /// Text, a page and files read back as written, from a pasteboard of the test's own, + /// never the user's. + #[test] + fn the_pasteboard_reads_back_text_pages_and_files() { + let board: Retained = + unsafe { msg_send_id![class!(NSPasteboard), pasteboardWithUniqueName] }; + let mut clipboard = Clipboard(board.clone()); + clipboard.set_text("Notes \u{2713}".into()).unwrap(); + assert_eq!(clipboard.get_text().unwrap(), "Notes \u{2713}"); + assert_eq!(clipboard.get_html(), None); + assert!(clipboard.get_files().is_empty()); + unsafe { + let _: isize = msg_send![&board, clearContents]; + let html = NSString::from_str("

Page

"); + let _: bool = + msg_send![&board, setString: &*html, forType: &*NSString::from_str("public.html")]; + } + assert_eq!(clipboard.get_html().as_deref(), Some("

Page

")); + let path = "/tmp/Notes and Plans/Plans.one"; + unsafe { + let url = objc2_foundation::NSURL::fileURLWithPath(&NSString::from_str(path)); + let urls: Retained = msg_send_id![class!(NSArray), arrayWithObject: &*url]; + let _: isize = msg_send![&board, clearContents]; + let _: bool = msg_send![&board, writeObjects: &*urls]; + } + assert_eq!(clipboard.get_files(), [std::path::PathBuf::from(path)]); + unsafe { msg_send![&board, releaseGlobally] } + } + #[link(name = "Security", kind = "framework")] unsafe extern "C" { fn SecKeychainCreate( -- 2.54.0