diff --git a/crates/platform/src/cf.rs b/crates/platform/src/cf.rs new file mode 100644 index 0000000..dcd3a7e --- /dev/null +++ b/crates/platform/src/cf.rs @@ -0,0 +1,285 @@ +//! Minimal CoreFoundation bindings. +//! +//! Provides `CFStringRef` creation from Rust strings and RAII reference +//! counting via [`CfString`]. These bindings are needed for passing string +//! parameters to AppKit APIs (window titles, menu items, etc). +//! +//! # Safety +//! +//! This module contains `unsafe` code for FFI with CoreFoundation. +//! The `platform` crate is one of the few crates where `unsafe` is permitted. + +use std::fmt; +use std::os::raw::c_void; +use std::ptr::NonNull; + +// --------------------------------------------------------------------------- +// Raw FFI types +// --------------------------------------------------------------------------- + +/// Opaque CoreFoundation string type. +#[repr(C)] +pub struct __CFString { + _private: [u8; 0], +} + +/// A reference to a CoreFoundation string (immutable). +pub type CFStringRef = *const __CFString; + +/// CoreFoundation index/size type. +pub type CFIndex = isize; + +/// CoreFoundation string encoding identifier. +pub type CFStringEncoding = u32; + +/// CoreFoundation Boolean type. +pub type CFBoolean = u8; + +/// UTF-8 encoding constant. +pub const K_CF_STRING_ENCODING_UTF8: CFStringEncoding = 0x0800_0100; + +// --------------------------------------------------------------------------- +// Raw FFI bindings to CoreFoundation.framework +// --------------------------------------------------------------------------- + +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFStringCreateWithBytes( + alloc: *const c_void, + bytes: *const u8, + num_bytes: CFIndex, + encoding: CFStringEncoding, + is_external_representation: CFBoolean, + ) -> CFStringRef; + + fn CFRetain(cf: *const c_void) -> *const c_void; + fn CFRelease(cf: *const c_void); + + fn CFStringGetLength(string: CFStringRef) -> CFIndex; + fn CFStringGetCString( + string: CFStringRef, + buffer: *mut u8, + buffer_size: CFIndex, + encoding: CFStringEncoding, + ) -> CFBoolean; +} + +// --------------------------------------------------------------------------- +// Public wrappers for CFRetain / CFRelease +// --------------------------------------------------------------------------- + +/// Increment the reference count of a CoreFoundation object. +/// +/// # Safety +/// +/// `ptr` must point to a valid CoreFoundation object. +#[inline] +pub unsafe fn cf_retain(ptr: *const c_void) -> *const c_void { + CFRetain(ptr) +} + +/// Decrement the reference count of a CoreFoundation object. +/// +/// # Safety +/// +/// `ptr` must point to a valid CoreFoundation object with a positive +/// reference count. The object may be deallocated after this call. +#[inline] +pub unsafe fn cf_release(ptr: *const c_void) { + CFRelease(ptr); +} + +// --------------------------------------------------------------------------- +// CfString — RAII wrapper around CFStringRef +// --------------------------------------------------------------------------- + +/// An owned CoreFoundation string that calls `CFRelease` on drop. +/// +/// Created from a Rust `&str` via [`CfString::new`]. The underlying +/// `CFStringRef` can be borrowed with [`CfString::as_ref`] for passing +/// to AppKit APIs. +pub struct CfString(NonNull<__CFString>); + +impl CfString { + /// Create a `CfString` from a Rust string slice. + /// + /// Returns `None` if CoreFoundation fails to create the string (should + /// only happen for extremely large strings or out-of-memory conditions). + pub fn new(s: &str) -> Option { + let ptr = unsafe { + CFStringCreateWithBytes( + std::ptr::null(), // default allocator + s.as_ptr(), + s.len() as CFIndex, + K_CF_STRING_ENCODING_UTF8, + 0, // not external representation + ) + }; + // CFStringCreateWithBytes returns null on failure. + NonNull::new(ptr as *mut __CFString).map(CfString) + } + + /// Return the raw `CFStringRef` for passing to CoreFoundation / AppKit APIs. + #[inline] + pub fn as_cf_ref(&self) -> CFStringRef { + self.0.as_ptr() as CFStringRef + } + + /// Return the raw pointer as `*const c_void` for APIs that take a generic + /// CoreFoundation type reference. + #[inline] + pub fn as_void_ptr(&self) -> *const c_void { + self.0.as_ptr() as *const c_void + } + + /// Get the length of the string in UTF-16 code units. + pub fn len(&self) -> usize { + let len = unsafe { CFStringGetLength(self.as_cf_ref()) }; + len as usize + } + + /// Returns `true` if the string is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Try to extract the string contents as a Rust `String`. + /// + /// This is primarily useful for testing and debugging. + pub fn to_string_lossy(&self) -> String { + // Allocate a buffer large enough for the UTF-8 representation. + // CFStringGetCString needs space for a NUL terminator. + // Worst case: each UTF-16 code unit -> 3 UTF-8 bytes + NUL. + let max_len = self.len() * 3 + 1; + let mut buf = vec![0u8; max_len]; + + let ok = unsafe { + CFStringGetCString( + self.as_cf_ref(), + buf.as_mut_ptr(), + buf.len() as CFIndex, + K_CF_STRING_ENCODING_UTF8, + ) + }; + + if ok != 0 { + // Find the NUL terminator. + let nul_pos = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..nul_pos]).into_owned() + } else { + String::new() + } + } +} + +impl Clone for CfString { + fn clone(&self) -> CfString { + unsafe { cf_retain(self.as_void_ptr()) }; + CfString(self.0) + } +} + +impl Drop for CfString { + fn drop(&mut self) { + unsafe { cf_release(self.as_void_ptr()) }; + } +} + +impl fmt::Debug for CfString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "CfString(\"{}\")", self.to_string_lossy()) + } +} + +impl fmt::Display for CfString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_string_lossy()) + } +} + +// SAFETY: CFString is an immutable, reference-counted CoreFoundation type. +// CFRetain/CFRelease are thread-safe. +unsafe impl Send for CfString {} +unsafe impl Sync for CfString {} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_cfstring_from_str() { + let s = CfString::new("Hello, world!").expect("should create CFString"); + assert_eq!(s.to_string_lossy(), "Hello, world!"); + } + + #[test] + fn cfstring_length() { + let s = CfString::new("abc").expect("should create CFString"); + assert_eq!(s.len(), 3); + } + + #[test] + fn cfstring_empty() { + let s = CfString::new("").expect("should create empty CFString"); + assert!(s.is_empty()); + assert_eq!(s.len(), 0); + assert_eq!(s.to_string_lossy(), ""); + } + + #[test] + fn cfstring_unicode() { + let s = CfString::new("caf\u{00e9}").expect("should handle unicode"); + assert_eq!(s.to_string_lossy(), "caf\u{00e9}"); + // "caf\u{e9}" is 4 UTF-16 code units + assert_eq!(s.len(), 4); + } + + #[test] + fn cfstring_emoji() { + // Emoji outside BMP: encoded as surrogate pair in UTF-16 (2 code units) + let s = CfString::new("\u{1F600}").expect("should handle emoji"); + assert_eq!(s.to_string_lossy(), "\u{1F600}"); + assert_eq!(s.len(), 2); // surrogate pair + } + + #[test] + fn cfstring_clone_and_drop() { + let s = CfString::new("test clone").expect("should create"); + let s2 = s.clone(); + assert_eq!(s.to_string_lossy(), s2.to_string_lossy()); + // Both should be droppable without crash (refcount was incremented). + drop(s); + assert_eq!(s2.to_string_lossy(), "test clone"); + } + + #[test] + fn cfstring_debug_format() { + let s = CfString::new("debug").expect("should create"); + let debug = format!("{:?}", s); + assert!(debug.contains("CfString")); + assert!(debug.contains("debug")); + } + + #[test] + fn cfstring_display_format() { + let s = CfString::new("display").expect("should create"); + let display = format!("{}", s); + assert_eq!(display, "display"); + } + + #[test] + fn cfstring_as_cf_ref_not_null() { + let s = CfString::new("ptr test").expect("should create"); + assert!(!s.as_cf_ref().is_null()); + } + + #[test] + fn cfstring_as_void_ptr_not_null() { + let s = CfString::new("void test").expect("should create"); + assert!(!s.as_void_ptr().is_null()); + } +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index a5b7dd2..1139cc8 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -1,3 +1,4 @@ //! Minimal macOS platform layer — Obj-C FFI, AppKit, CoreGraphics, Metal. +pub mod cf; pub mod objc;