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"...@@ -35,7 +35,6 @@ web-time = "1.1"
3535
36[target.'cfg(not(target_arch = "wasm32"))'.dependencies]36[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
37notebook = { path = "../notebook", features = ["smb"] }37notebook = { path = "../notebook", features = ["smb"] }
38arboard = { version = "3.6.1", default-features = false }
39accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }38accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
40# Updates: fetched over HTTPS, verified with ed25519 and SHA-256. The browser updates by39# Updates: fetched over HTTPS, verified with ed25519 and SHA-256. The browser updates by
41# reloading.40# reloading.
...@@ -61,7 +60,7 @@ objc2-foundation = { version = "0.2.2", default-features = false, features = ["N...@@ -61,7 +60,7 @@ objc2-foundation = { version = "0.2.2", default-features = false, features = ["N
61block2 = "0.5.1"60block2 = "0.5.1"
6261
63[target.'cfg(not(any(target_os = "macos", target_arch = "wasm32")))'.dependencies]62[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.
65arboard = { version = "3.6.1", default-features = false, features = ["image-data"] }64arboard = { version = "3.6.1", default-features = false, features = ["image-data"] }
6665
67[target.'cfg(windows)'.dependencies]66[target.'cfg(windows)'.dependencies]
crates/snowbound/src/macos.rs+89-13
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1use crate::commands;1use crate::commands;
2use canvas::date::DateField;2use canvas::date::DateField;
3use objc2::{3use objc2::{
4 ClassType, DeclaredClass,4 ClassType, DeclaredClass, class,
5 declare::ClassBuilder,5 declare::ClassBuilder,
6 declare_class, msg_send, msg_send_id, mutability,6 declare_class, msg_send, msg_send_id, mutability,
7 rc::{Allocated, Retained},7 rc::{Allocated, Retained},
...@@ -176,36 +176,63 @@ pub fn traffic_lights(window: &Window) -> [[f32; 2]; 3] {...@@ -176,36 +176,63 @@ pub fn traffic_lights(window: &Window) -> [[f32; 2]; 3] {
176 }176 }
177}177}
178178
179pub struct Clipboard(arboard::Clipboard);179/// The general pasteboard.
180pub struct Clipboard(Retained<AnyObject>);
180181
181impl Clipboard {182impl Clipboard {
182 pub fn new(_: &Window) -> Result<Self, arboard::Error> {183 pub fn new(_: &Window) -> Result<Self, &'static str> {
183 arboard::Clipboard::new().map(Self)184 let board: Option<Retained<AnyObject>> =
185 unsafe { msg_send_id![class!(NSPasteboard), generalPasteboard] };
186 board.map(Self).ok_or("No pasteboard is available")
184 }187 }
185188
186 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {189 pub fn set_text(&mut self, text: String) -> Result<(), &'static str> {
187 self.0.set_text(text)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")
188 }196 }
189197
190 pub fn get_text(&mut self) -> Result<String, arboard::Error> {198 pub fn get_text(&mut self) -> Result<String, &'static str> {
191 self.0.get_text()199 self.string(TEXT).ok_or("The pasteboard holds no text")
192 }200 }
193201
202 /// The files copied, as Finder puts them on the pasteboard.
194 pub fn get_files(&mut self) -> Vec<std::path::PathBuf> {203 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 })
196 }225 }
197226
198 pub fn get_html(&mut self) -> Option<String> {227 pub fn get_html(&mut self) -> Option<String> {
199 self.0.get().html().ok()228 self.string("public.html")
200 }229 }
201230
202 /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG.231 /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG.
203 pub fn get_picture(&mut self) -> Option<Vec<u8>> {232 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
204 unsafe {233 unsafe {
205 let board: Retained<AnyObject> =
206 msg_send_id![AnyClass::get("NSPasteboard")?, generalPasteboard];
207 let data = |kind: &str| -> Option<Retained<AnyObject>> {234 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)]
209 };236 };
210 let png = match data("public.png") {237 let png = match data("public.png") {
211 Some(png) => png,238 Some(png) => png,
...@@ -214,8 +241,27 @@ impl Clipboard {...@@ -214,8 +241,27 @@ impl Clipboard {
214 Some(ns_data_bytes(&png))241 Some(ns_data_bytes(&png))
215 }242 }
216 }243 }
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 }
217}260}
218261
262/// NSPasteboardTypeString.
263const TEXT: &str = "public.utf8-plain-text";
264
219/// TIFF `data` as PNG data, keeping its resolution.265/// TIFF `data` as PNG data, keeping its resolution.
220unsafe fn tiff_png(data: &AnyObject) -> Option<Retained<AnyObject>> {266unsafe fn tiff_png(data: &AnyObject) -> Option<Retained<AnyObject>> {
221 const PNG: usize = 4; // NSBitmapImageFileTypePNG267 const PNG: usize = 4; // NSBitmapImageFileTypePNG
...@@ -1492,8 +1538,38 @@ pub fn input_language() -> String {...@@ -1492,8 +1538,38 @@ pub fn input_language() -> String {
14921538
1493#[cfg(test)]1539#[cfg(test)]
1494mod tests {1540mod tests {
1541 use super::*;
1495 use crate::library::{Login, Mount};1542 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
1497 #[link(name = "Security", kind = "framework")]1573 #[link(name = "Security", kind = "framework")]
1498 unsafe extern "C" {1574 unsafe extern "C" {
1499 fn SecKeychainCreate(1575 fn SecKeychainCreate(