authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 17:53:36-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 19:54:16-07:00
logb9dec006c038304dcdfaa441feb86a92361f693f
tree2ea5403c5ab0bd7f6f128349fdc99141b4ea2c73
parent309ad5f20a2a5bcb3ed236cb23f75d2d18064f3d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

2 files changed, 90 insertions(+), 15 deletions(-)

crates/snowbound/Cargo.toml+1-2
......@@ -35,7 +35,6 @@ web-time = "1.1"
3535
3636[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
3737notebook = { path = "../notebook", features = ["smb"] }
38arboard = { version = "3.6.1", default-features = false }
3938accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
4039# Updates: fetched over HTTPS, verified with ed25519 and SHA-256. The browser updates by
4140# reloading.
......@@ -61,7 +60,7 @@ objc2-foundation = { version = "0.2.2", default-features = false, features = ["N
6160block2 = "0.5.1"
6261
6362[target.'cfg(not(any(target_os = "macos", target_arch = "wasm32")))'.dependencies]
64# Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself.
63# macOS reads and writes the pasteboard itself.
6564arboard = { version = "3.6.1", default-features = false, features = ["image-data"] }
6665
6766[target.'cfg(windows)'.dependencies]
crates/snowbound/src/macos.rs+89-13
......@@ -1,7 +1,7 @@
11use crate::commands;
22use canvas::date::DateField;
33use objc2::{
4 ClassType, DeclaredClass,
4 ClassType, DeclaredClass, class,
55 declare::ClassBuilder,
66 declare_class, msg_send, msg_send_id, mutability,
77 rc::{Allocated, Retained},
......@@ -176,36 +176,63 @@ pub fn traffic_lights(window: &Window) -> [[f32; 2]; 3] {
176176 }
177177}
178178
179pub struct Clipboard(arboard::Clipboard);
179/// The general pasteboard.
180pub struct Clipboard(Retained<AnyObject>);
180181
181182impl Clipboard {
182 pub fn new(_: &Window) -> Result<Self, arboard::Error> {
183 arboard::Clipboard::new().map(Self)
183 pub fn new(_: &Window) -> Result<Self, &'static str> {
184 let board: Option<Retained<AnyObject>> =
185 unsafe { msg_send_id![class!(NSPasteboard), generalPasteboard] };
186 board.map(Self).ok_or("No pasteboard is available")
184187 }
185188
186 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {
187 self.0.set_text(text)
189 pub fn set_text(&mut self, text: String) -> Result<(), &'static str> {
190 let set: bool = unsafe {
191 let _: isize = msg_send![&self.0, clearContents];
192 let (text, kind) = (NSString::from_str(&text), NSString::from_str(TEXT));
193 msg_send![&self.0, setString: &*text, forType: &*kind]
194 };
195 set.then_some(()).ok_or("The pasteboard refused the text")
188196 }
189197
190 pub fn get_text(&mut self) -> Result<String, arboard::Error> {
191 self.0.get_text()
198 pub fn get_text(&mut self) -> Result<String, &'static str> {
199 self.string(TEXT).ok_or("The pasteboard holds no text")
192200 }
193201
202 /// The files copied, as Finder puts them on the pasteboard.
194203 pub fn get_files(&mut self) -> Vec<std::path::PathBuf> {
195 self.0.get().file_list().unwrap_or_default()
204 objc2::rc::autoreleasepool(|_| unsafe {
205 let url: *const AnyObject = (class!(NSURL) as *const AnyClass).cast();
206 let classes: Retained<AnyObject> = msg_send_id![class!(NSArray), arrayWithObject: url];
207 let yes: Retained<AnyObject> = msg_send_id![class!(NSNumber), numberWithBool: true];
208 let key = NSString::from_str("NSPasteboardURLReadingFileURLsOnlyKey");
209 let options: Retained<AnyObject> =
210 msg_send_id![class!(NSDictionary), dictionaryWithObject: &*yes, forKey: &*key];
211 let urls: Option<Retained<AnyObject>> =
212 msg_send_id![&self.0, readObjectsForClasses: &*classes, options: &*options];
213 let Some(urls) = urls else {
214 return Vec::new();
215 };
216 let count: usize = msg_send![&urls, count];
217 (0..count)
218 .filter_map(|index| {
219 let url: Retained<AnyObject> = msg_send_id![&urls, objectAtIndex: index];
220 let path: Option<Retained<NSString>> = msg_send_id![&url, path];
221 Some(path?.to_string().into())
222 })
223 .collect()
224 })
196225 }
197226
198227 pub fn get_html(&mut self) -> Option<String> {
199 self.0.get().html().ok()
228 self.string("public.html")
200229 }
201230
202231 /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG.
203232 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
204233 unsafe {
205 let board: Retained<AnyObject> =
206 msg_send_id![AnyClass::get("NSPasteboard")?, generalPasteboard];
207234 let data = |kind: &str| -> Option<Retained<AnyObject>> {
208 msg_send_id![&board, dataForType: &*NSString::from_str(kind)]
235 msg_send_id![&self.0, dataForType: &*NSString::from_str(kind)]
209236 };
210237 let png = match data("public.png") {
211238 Some(png) => png,
......@@ -214,8 +241,27 @@ impl Clipboard {
214241 Some(ns_data_bytes(&png))
215242 }
216243 }
244
245 /// The first item's string of `kind`, where the pasteboard's own `stringForType:` joins
246 /// every item's.
247 fn string(&self, kind: &str) -> Option<String> {
248 objc2::rc::autoreleasepool(|_| unsafe {
249 let items: Option<Retained<AnyObject>> = msg_send_id![&self.0, pasteboardItems];
250 let items = items?;
251 let count: usize = msg_send![&items, count];
252 let kind = NSString::from_str(kind);
253 (0..count).find_map(|index| {
254 let item: Retained<AnyObject> = msg_send_id![&items, objectAtIndex: index];
255 let string: Option<Retained<NSString>> = msg_send_id![&item, stringForType: &*kind];
256 Some(string?.to_string())
257 })
258 })
259 }
217260}
218261
262/// NSPasteboardTypeString.
263const TEXT: &str = "public.utf8-plain-text";
264
219265/// TIFF `data` as PNG data, keeping its resolution.
220266unsafe fn tiff_png(data: &AnyObject) -> Option<Retained<AnyObject>> {
221267 const PNG: usize = 4; // NSBitmapImageFileTypePNG
......@@ -1492,8 +1538,38 @@ pub fn input_language() -> String {
14921538
14931539#[cfg(test)]
14941540mod tests {
1541 use super::*;
14951542 use crate::library::{Login, Mount};
14961543
1544 /// Text, a page and files read back as written, from a pasteboard of the test's own,
1545 /// never the user's.
1546 #[test]
1547 fn the_pasteboard_reads_back_text_pages_and_files() {
1548 let board: Retained<AnyObject> =
1549 unsafe { msg_send_id![class!(NSPasteboard), pasteboardWithUniqueName] };
1550 let mut clipboard = Clipboard(board.clone());
1551 clipboard.set_text("Notes \u{2713}".into()).unwrap();
1552 assert_eq!(clipboard.get_text().unwrap(), "Notes \u{2713}");
1553 assert_eq!(clipboard.get_html(), None);
1554 assert!(clipboard.get_files().is_empty());
1555 unsafe {
1556 let _: isize = msg_send![&board, clearContents];
1557 let html = NSString::from_str("<p>Page</p>");
1558 let _: bool =
1559 msg_send![&board, setString: &*html, forType: &*NSString::from_str("public.html")];
1560 }
1561 assert_eq!(clipboard.get_html().as_deref(), Some("<p>Page</p>"));
1562 let path = "/tmp/Notes and Plans/Plans.one";
1563 unsafe {
1564 let url = objc2_foundation::NSURL::fileURLWithPath(&NSString::from_str(path));
1565 let urls: Retained<AnyObject> = msg_send_id![class!(NSArray), arrayWithObject: &*url];
1566 let _: isize = msg_send![&board, clearContents];
1567 let _: bool = msg_send![&board, writeObjects: &*urls];
1568 }
1569 assert_eq!(clipboard.get_files(), [std::path::PathBuf::from(path)]);
1570 unsafe { msg_send![&board, releaseGlobally] }
1571 }
1572
14971573 #[link(name = "Security", kind = "framework")]
14981574 unsafe extern "C" {
14991575 fn SecKeychainCreate(