hjkl-clipboard 0.40.0

Cross-platform clipboard library with rich types, async support, and OSC 52 fallback for SSH
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! macOS clipboard backend via NSPasteboard (raw `objc_msgSend`).
//!
//! Links AppKit + Foundation frameworks and libobjc. The `objc_msgSend`
//! calling convention differs between x86_64 and ARM64 — each call site
//! must cast the function pointer to the exact (self, sel, args...) -> Ret
//! signature, matching the Objective-C method prototype precisely.
//!
//! No `objc`, `objc2`, or `cocoa-foundation` crate — raw FFI only.

use std::ffi::{CStr, CString, c_char, c_void};
use std::sync::OnceLock;

// ---------------------------------------------------------------------------
// Autorelease pool guard.
// ---------------------------------------------------------------------------
//
// NSPasteboard ops allocate autoreleased objects (NSData, NSString, NSArray).
// On non-main threads there is no implicit pool, so objects accumulate without
// a pool in scope. Wrapping each public method body in a pool ensures objects
// are released promptly regardless of the calling thread.

unsafe extern "C" {
    fn objc_autoreleasePoolPush() -> *mut c_void;
    fn objc_autoreleasePoolPop(pool: *mut c_void);
}

/// RAII autorelease pool. `Drop` calls `objc_autoreleasePoolPop` so the pool
/// is drained even if the body panics.
struct AutoreleasePool {
    token: *mut c_void,
}

impl Drop for AutoreleasePool {
    fn drop(&mut self) {
        // SAFETY: `token` was returned by `objc_autoreleasePoolPush` on this
        // thread. Calling `pop` with the matching token drains the pool and is
        // the documented way to balance a push.
        unsafe { objc_autoreleasePoolPop(self.token) }
    }
}

/// Push a new autorelease pool and return a guard that pops it on drop.
fn pool() -> AutoreleasePool {
    AutoreleasePool {
        // SAFETY: `objc_autoreleasePoolPush` is safe to call on any thread at
        // any time and always returns a valid (possibly opaque) token.
        token: unsafe { objc_autoreleasePoolPush() },
    }
}

use crate::{ClipboardError, MimeType, Selection};

use super::Backend;

// ---------------------------------------------------------------------------
// Type aliases.
// ---------------------------------------------------------------------------

/// Pointer-sized Objective-C object reference.
type Id = *mut c_void;

/// Objective-C class (same representation as `Id`).
type Class = *mut c_void;

/// Opaque selector pointer.
type Sel = *const c_void;

/// NSUInteger — matches pointer width on both x86_64 and ARM64.
type NSUInteger = usize;

// ---------------------------------------------------------------------------
// Framework + libobjc linking.
// ---------------------------------------------------------------------------

#[link(name = "AppKit", kind = "framework")]
unsafe extern "C" {}

#[link(name = "Foundation", kind = "framework")]
unsafe extern "C" {}

#[link(name = "objc")]
unsafe extern "C" {
    /// Register (or look up) an Objective-C selector by name.
    fn sel_registerName(name: *const c_char) -> Sel;

    /// Look up an Objective-C class by name. Returns NULL if not found.
    fn objc_getClass(name: *const c_char) -> Class;

    /// Universal Objective-C message-send stub. We never call this signature
    /// directly — each call site transmutes the pointer to the exact prototype
    /// matching the called method's (self, sel, args...) -> Ret signature.
    /// ARM64 ABI: all arguments including self and sel go in registers; the
    /// prototype must be exact. x86_64 ABI: same principle, different
    /// registers. Wrong prototype = undefined behaviour / segfault.
    fn objc_msgSend();
}

// ---------------------------------------------------------------------------
// msg helpers — transmute per call site, ABI-guarded by `MsgAbi`.
// ---------------------------------------------------------------------------
//
// Each helper transmutes `objc_msgSend` to the concrete signature matching the
// number of extra arguments. The `MsgAbi` bound on every generic type is a
// COMPILE-TIME guard: the transmute is only sound for values passed/returned in
// INTEGER registers under the Apple `objc_msgSend` ABI (x86_64 + ARM64) —
// pointers and integer scalars (including `bool`, `NSUInteger`, `NSInteger`). A
// future call site that tries to pass or return an `f32`/`f64`/SIMD vector or a
// large/aggregate value — which use a different register class and would
// corrupt the stack / ABI — fails to compile because that type does not
// implement `MsgAbi`. This cannot verify the argument order/count against the
// ObjC method prototype (that needs typed selectors), but it rules out the
// ABI-class mismatches that actually cause UB / segfaults — the realistic
// maintenance hazard. Every current call site uses only pointer/integer/bool
// types, so the bound is satisfied without changing behaviour.

/// Marker for types that ride in integer registers under the Apple
/// `objc_msgSend` ABI and are therefore sound to pass through the transmuted
/// stub. Private to this module, so it is effectively sealed — no external
/// code can widen the ABI-safe set. Note `impl<T> for *const/*mut T` requires
/// `T: Sized` (the default), so FAT pointers (`*const [u8]`, `*const dyn _`)
/// are intentionally excluded — they are two words and would break the ABI.
trait MsgAbi {}

impl<T> MsgAbi for *const T {}
impl<T> MsgAbi for *mut T {}
impl MsgAbi for bool {}
impl MsgAbi for usize {}
impl MsgAbi for isize {}
impl MsgAbi for u8 {}
impl MsgAbi for i8 {}
impl MsgAbi for u16 {}
impl MsgAbi for i16 {}
impl MsgAbi for u32 {}
impl MsgAbi for i32 {}
impl MsgAbi for u64 {}
impl MsgAbi for i64 {}

/// Message with no extra arguments.
unsafe fn msg0<R: MsgAbi>(obj: Id, sel: Sel) -> R {
    // SAFETY: `obj` is a valid Objective-C object, `sel` is a registered
    // selector. The return type `R` must exactly match the ObjC method's
    // return type. ARM64/x86_64 ABI requires this exact prototype cast.
    let f: unsafe extern "C" fn(Id, Sel) -> R =
        // SAFETY: transmuting the stub to the concrete signature — see module doc.
        unsafe { std::mem::transmute(objc_msgSend as *const ()) };
    // SAFETY: the transmuted pointer has the correct ABI for this call.
    unsafe { f(obj, sel) }
}

/// Message with one extra argument.
unsafe fn msg1<A: MsgAbi, R: MsgAbi>(obj: Id, sel: Sel, a: A) -> R {
    // SAFETY: same as `msg0`; A must exactly match the first argument type.
    let f: unsafe extern "C" fn(Id, Sel, A) -> R =
        // SAFETY: transmuting the stub — see module doc.
        unsafe { std::mem::transmute(objc_msgSend as *const ()) };
    // SAFETY: the transmuted pointer has the correct ABI for this call.
    unsafe { f(obj, sel, a) }
}

/// Message with two extra arguments.
unsafe fn msg2<A: MsgAbi, B: MsgAbi, R: MsgAbi>(obj: Id, sel: Sel, a: A, b: B) -> R {
    // SAFETY: same as `msg0`; A and B must match the method's argument types.
    let f: unsafe extern "C" fn(Id, Sel, A, B) -> R =
        // SAFETY: transmuting the stub — see module doc.
        unsafe { std::mem::transmute(objc_msgSend as *const ()) };
    // SAFETY: the transmuted pointer has the correct ABI for this call.
    unsafe { f(obj, sel, a, b) }
}

// ---------------------------------------------------------------------------
// Selector cache.
// ---------------------------------------------------------------------------
//
// Selectors are stable for the process lifetime (Apple ABI guarantee).
// Store as `usize` because raw pointers are not `Send`; cast back at use.

macro_rules! sel_cached {
    ($fn_name:ident, $name:literal) => {
        fn $fn_name() -> Sel {
            static S: OnceLock<usize> = OnceLock::new();
            // SAFETY: the literal ends with `\0`, satisfying the C-string
            // contract. `sel_registerName` is safe to call from any thread;
            // it returns a pointer stable for the process lifetime.
            *S.get_or_init(|| unsafe {
                sel_registerName(concat!($name, "\0").as_ptr().cast()) as usize
            }) as Sel
        }
    };
}

sel_cached!(sel_general_pasteboard, "generalPasteboard");
sel_cached!(sel_clear_contents, "clearContents");
sel_cached!(sel_set_data_for_type, "setData:forType:");
sel_cached!(sel_data_for_type, "dataForType:");
sel_cached!(sel_types, "types");
sel_cached!(sel_count, "count");
sel_cached!(sel_object_at_index, "objectAtIndex:");
sel_cached!(sel_utf8_string, "UTF8String");
sel_cached!(sel_length, "length");
sel_cached!(sel_bytes, "bytes");
sel_cached!(sel_data_with_bytes_length, "dataWithBytes:length:");
sel_cached!(sel_string_with_utf8_string, "stringWithUTF8String:");

// ---------------------------------------------------------------------------
// Class cache.
// ---------------------------------------------------------------------------

macro_rules! class_cached {
    ($fn_name:ident, $name:literal) => {
        fn $fn_name() -> Class {
            static C: OnceLock<usize> = OnceLock::new();
            // SAFETY: the literal ends with `\0`. `objc_getClass` is thread-safe
            // and returns a stable pointer (NULL if the class is absent, which
            // would indicate a misconfigured SDK linkage).
            *C.get_or_init(|| unsafe {
                objc_getClass(concat!($name, "\0").as_ptr().cast()) as usize
            }) as Class
        }
    };
}

class_cached!(class_nspasteboard, "NSPasteboard");
class_cached!(class_nsdata, "NSData");
class_cached!(class_nsstring, "NSString");

// ---------------------------------------------------------------------------
// NSPasteboard singleton.
// ---------------------------------------------------------------------------

/// Returns `[NSPasteboard generalPasteboard]`.
unsafe fn general_pasteboard() -> Id {
    // SAFETY: `class_nspasteboard()` returns the NSPasteboard class pointer.
    // `sel_general_pasteboard()` is the correct class-method selector.
    // The result is an autoreleased singleton — do not release it.
    unsafe { msg0::<Id>(class_nspasteboard(), sel_general_pasteboard()) }
}

// ---------------------------------------------------------------------------
// NSString helpers.
// ---------------------------------------------------------------------------

/// Construct an `NSString` from a Rust `&str` via `stringWithUTF8String:`.
///
/// Returns `nil` on allocation failure (extremely rare). The returned object
/// is autoreleased; its lifetime is tied to the current autorelease pool.
/// For our use (immediate argument to another ObjC call) this is safe.
unsafe fn nsstring_from_str(s: &str) -> Id {
    let cstr = CString::new(s).expect("NUL byte in clipboard type string");
    // SAFETY: `cstr.as_ptr()` is a valid NUL-terminated C string. The class
    // method `stringWithUTF8String:` copies the bytes internally.
    unsafe {
        msg1::<*const c_char, Id>(
            class_nsstring(),
            sel_string_with_utf8_string(),
            cstr.as_ptr(),
        )
    }
}

/// Convert an `NSString` to a Rust `String`, returning `None` on nil or
/// invalid UTF-8.
unsafe fn nsstring_to_string(s: Id) -> Option<String> {
    if s.is_null() {
        return None;
    }
    // SAFETY: `s` is a non-null NSString. `UTF8String` returns a C string
    // whose lifetime is tied to `s` (and the autorelease pool). We copy the
    // bytes into a Rust String before the pool can drain.
    let utf8: *const c_char = unsafe { msg0::<*const c_char>(s, sel_utf8_string()) };
    if utf8.is_null() {
        return None;
    }
    // SAFETY: `utf8` is non-null and points to a valid NUL-terminated C string
    // owned by `s`. We copy via `to_str().map(String::from)` before returning.
    unsafe { CStr::from_ptr(utf8) }
        .to_str()
        .ok()
        .map(String::from)
}

// ---------------------------------------------------------------------------
// NSData helpers.
// ---------------------------------------------------------------------------

/// Construct an `NSData` object from a Rust byte slice via
/// `dataWithBytes:length:`. The returned object is autoreleased.
unsafe fn nsdata_from_bytes(bytes: &[u8]) -> Id {
    // SAFETY: `bytes.as_ptr()` is valid for `bytes.len()` readable bytes.
    // NSData copies the bytes internally; the slice can be freed after the call.
    unsafe {
        msg2::<*const c_void, NSUInteger, Id>(
            class_nsdata(),
            sel_data_with_bytes_length(),
            bytes.as_ptr().cast(),
            bytes.len(),
        )
    }
}

/// Copy the bytes of an `NSData` object into a `Vec<u8>`.
unsafe fn nsdata_to_vec(data: Id) -> Vec<u8> {
    // SAFETY: `data` is a non-null NSData. `length` and `bytes` are safe
    // ObjC accessors; we copy the slice before the object can be released.
    let len: NSUInteger = unsafe { msg0(data, sel_length()) };
    let ptr: *const c_void = unsafe { msg0(data, sel_bytes()) };
    if ptr.is_null() || len == 0 {
        return Vec::new();
    }
    // SAFETY: `ptr` is valid for `len` readable bytes owned by `data`.
    // `slice::from_raw_parts` is safe here; `to_vec()` copies immediately.
    let slice = unsafe { std::slice::from_raw_parts(ptr.cast::<u8>(), len) };
    slice.to_vec()
}

// ---------------------------------------------------------------------------
// UTI / MimeType mapping.
// ---------------------------------------------------------------------------

/// Map a `MimeType` to the NSPasteboard type string (UTI or custom).
///
/// Returns `None` for types that cannot be expressed on macOS (none currently;
/// `Custom(s)` passes through verbatim).
fn mime_to_uti(mime: &MimeType) -> Option<String> {
    match mime {
        MimeType::Text => Some("public.utf8-plain-text".into()),
        MimeType::Html => Some("public.html".into()),
        MimeType::Rtf => Some("public.rtf".into()),
        MimeType::UriList => Some("text/uri-list".into()),
        MimeType::Png => Some("public.png".into()),
        MimeType::Custom(s) => Some(s.clone()),
        // `#[non_exhaustive]` — unknown future variants added in other crates.
        #[allow(unreachable_patterns)]
        _ => None,
    }
}

/// Map a UTI/type-string back to a `MimeType`.
///
/// Returns `None` for unknown types to avoid polluting `available()` with
/// macOS-internal type strings that callers cannot act on.
fn uti_to_mime(name: &str) -> Option<MimeType> {
    match name {
        "public.utf8-plain-text" | "NSStringPboardType" => Some(MimeType::Text),
        "public.html" => Some(MimeType::Html),
        "public.rtf" => Some(MimeType::Rtf),
        "text/uri-list" => Some(MimeType::UriList),
        "public.png" => Some(MimeType::Png),
        _ => None,
    }
}

// ---------------------------------------------------------------------------
// Backend impl.
// ---------------------------------------------------------------------------

/// macOS pasteboard backend. Unit struct — all state lives in NSPasteboard.
pub struct MacosBackend;

impl MacosBackend {
    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
    pub(crate) fn new() -> Self {
        Self
    }
}

impl Backend for MacosBackend {
    fn kind(&self) -> crate::BackendKind {
        crate::BackendKind::MacOs
    }

    fn capabilities(&self) -> crate::Capabilities {
        // NSPasteboard supports full sync matrix. No PRIMARY (mac concept absent).
        // Async stays default (UnsupportedAsync) — no native async pasteboard API.
        crate::Capabilities::WRITE
            | crate::Capabilities::READ
            | crate::Capabilities::CLEAR
            | crate::Capabilities::AVAILABLE
            | crate::Capabilities::IMAGE
            | crate::Capabilities::RICH_TEXT
            | crate::Capabilities::URI_LIST
    }

    fn set(&self, sel: Selection, mime: MimeType, bytes: &[u8]) -> Result<(), ClipboardError> {
        let _pool = pool();
        // macOS has no primary selection concept.
        if sel != Selection::Clipboard {
            return Err(ClipboardError::UnsupportedMime);
        }
        let uti = mime_to_uti(&mime).ok_or(ClipboardError::UnsupportedMime)?;
        // SAFETY: all ObjC calls below operate on autoreleased objects returned
        // from valid class methods. `general_pasteboard()` returns the process-
        // wide singleton; `clearContents` + `setData:forType:` are the
        // documented write path per Apple developer documentation. The `ok`
        // return from `setData:forType:` is BOOL (mapped to bool here).
        //
        // `_pool` guarantees the autorelease pool is in scope for the duration
        // of this method, so autoreleased objects (NSData, NSString) are
        // drained on return/panic rather than accumulating on the calling thread.
        unsafe {
            let pb = general_pasteboard();
            if pb.is_null() {
                return Err(ClipboardError::io_other("generalPasteboard returned nil"));
            }
            // `clearContents` must be called before any setData:forType: per
            // Apple docs. Returns NSInteger (change count); we discard it.
            let _change: isize = msg0(pb, sel_clear_contents());
            let data = nsdata_from_bytes(bytes);
            let ty = nsstring_from_str(&uti);
            let ok: bool = msg2(pb, sel_set_data_for_type(), data, ty);
            if !ok {
                return Err(ClipboardError::io_other("setData:forType: returned NO"));
            }
        }
        Ok(())
    }

    fn get(&self, sel: Selection, mime: MimeType) -> Result<Vec<u8>, ClipboardError> {
        let _pool = pool();
        // macOS has no primary selection concept.
        if sel != Selection::Clipboard {
            return Err(ClipboardError::UnsupportedMime);
        }
        let uti = mime_to_uti(&mime).ok_or(ClipboardError::UnsupportedMime)?;
        // SAFETY: `general_pasteboard()` returns the process-wide singleton.
        // `dataForType:` returns an autoreleased NSData (or nil if absent).
        // We copy its bytes immediately via `nsdata_to_vec` before any pool
        // drain can occur.
        unsafe {
            let pb = general_pasteboard();
            if pb.is_null() {
                return Err(ClipboardError::io_other("generalPasteboard returned nil"));
            }
            let ty = nsstring_from_str(&uti);
            let data: Id = msg1(pb, sel_data_for_type(), ty);
            if data.is_null() {
                return Err(ClipboardError::UnsupportedMime);
            }
            Ok(nsdata_to_vec(data))
        }
    }

    fn clear(&self, sel: Selection) -> Result<(), ClipboardError> {
        let _pool = pool();
        // macOS has no primary selection concept.
        if sel != Selection::Clipboard {
            return Err(ClipboardError::UnsupportedMime);
        }
        // SAFETY: `clearContents` is the documented way to clear NSPasteboard.
        // Returns NSInteger (change count); we discard it.
        unsafe {
            let pb = general_pasteboard();
            if pb.is_null() {
                return Err(ClipboardError::io_other("generalPasteboard returned nil"));
            }
            let _change: isize = msg0(pb, sel_clear_contents());
        }
        Ok(())
    }

    fn available(&self, sel: Selection) -> Result<Vec<MimeType>, ClipboardError> {
        let _pool = pool();
        // macOS has no primary selection concept — match set/get/clear and
        // surface `UnsupportedMime` so callers get a clear signal rather than
        // an empty list that misleadingly implies "primary works but is empty".
        if sel != Selection::Clipboard {
            return Err(ClipboardError::UnsupportedMime);
        }
        // SAFETY: `types` returns an autoreleased NSArray<NSString*> (or nil).
        // We iterate via `count` + `objectAtIndex:`, converting each element
        // with `nsstring_to_string`. All objects are autoreleased and valid for
        // the duration of the loop (no explicit pool drain between calls).
        unsafe {
            let pb = general_pasteboard();
            if pb.is_null() {
                return Ok(vec![]);
            }
            let types: Id = msg0(pb, sel_types());
            if types.is_null() {
                return Ok(vec![]);
            }
            let count: NSUInteger = msg0(types, sel_count());
            let mut out: Vec<MimeType> = Vec::new();
            for i in 0..count {
                let s: Id = msg1(types, sel_object_at_index(), i);
                let Some(name) = nsstring_to_string(s) else {
                    continue;
                };
                if let Some(mime) = uti_to_mime(&name)
                    && !out.contains(&mime)
                {
                    out.push(mime);
                }
            }
            Ok(out)
        }
    }
}