From 446be284326ff6a6f71afbad49f86b3764a7fbac Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Mon, 23 Feb 2026 21:12:39 +0100 Subject: [PATCH] Implement Objective-C runtime FFI bindings Add platform/src/objc.rs with: - Raw extern "C" bindings to libobjc.dylib: objc_msgSend, objc_getClass, sel_registerName, class_addMethod, objc_allocateClassPair, objc_registerClassPair, class_getName - Sel wrapper: registered selector with interned pointer equality - Class wrapper: class lookup, dynamic class creation, method addition - Id wrapper: non-null Objective-C object pointer - msg_send! macro: type-safe(ish) message dispatch via transmuted objc_msgSend (0-4 argument variants) - sel! and class! convenience macros - 12 unit tests covering class lookup, selector registration, alloc/init, custom class creation, method addition, and message dispatch This is the foundation all other platform FFI builds on. Co-Authored-By: Claude Opus 4.6 --- crates/platform/src/lib.rs | 2 + crates/platform/src/objc.rs | 583 ++++++++++++++++++++++++++++++++++++ 2 files changed, 585 insertions(+) create mode 100644 crates/platform/src/objc.rs diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 2a0fa99..a5b7dd2 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -1 +1,3 @@ //! Minimal macOS platform layer — Obj-C FFI, AppKit, CoreGraphics, Metal. + +pub mod objc; diff --git a/crates/platform/src/objc.rs b/crates/platform/src/objc.rs new file mode 100644 index 0000000..41c7442 --- /dev/null +++ b/crates/platform/src/objc.rs @@ -0,0 +1,583 @@ +//! Objective-C runtime FFI bindings and Rust abstractions. +//! +//! Provides safe(ish) wrappers around the Objective-C runtime for message +//! dispatch, class lookup, selector registration, and dynamic class creation. +//! +//! # Safety +//! +//! This module contains `unsafe` code for FFI with `libobjc.dylib`. +//! The `platform` crate is one of the few crates where `unsafe` is permitted. + +use std::ffi::CStr; +use std::fmt; +use std::os::raw::c_char; +use std::ptr::NonNull; + +// --------------------------------------------------------------------------- +// Raw FFI bindings to libobjc.dylib +// --------------------------------------------------------------------------- + +// Opaque types matching the ObjC runtime's internal structures. +// These are public because they appear in the signatures of public methods +// (e.g. `as_ptr`), but they are zero-sized and cannot be constructed. +#[repr(C)] +pub struct ObjcClass { + _private: [u8; 0], +} + +#[repr(C)] +pub struct ObjcObject { + _private: [u8; 0], +} + +#[repr(C)] +pub struct ObjcSelector { + _private: [u8; 0], +} + +/// An Objective-C method implementation function pointer. +/// +/// The actual signature varies per method, but the runtime treats all IMPs +/// as `extern "C" fn()` for registration purposes. +pub type Imp = unsafe extern "C" fn(); + +#[link(name = "objc", kind = "dylib")] +extern "C" { + fn objc_getClass(name: *const c_char) -> *mut ObjcClass; + fn sel_registerName(name: *const c_char) -> *mut ObjcSelector; + fn objc_allocateClassPair( + superclass: *mut ObjcClass, + name: *const c_char, + extra_bytes: usize, + ) -> *mut ObjcClass; + fn objc_registerClassPair(cls: *mut ObjcClass); + fn class_addMethod( + cls: *mut ObjcClass, + sel: *mut ObjcSelector, + imp: Imp, + types: *const c_char, + ) -> bool; + fn class_getName(cls: *mut ObjcClass) -> *const c_char; + + // objc_msgSend has a variadic ABI. On AArch64 the calling convention is + // the standard C calling convention (arguments in x0..x7, return in x0). + // We declare it with no extra arguments; the msg_send! macro transmutes + // the function pointer to the appropriate concrete signature at each call + // site. + fn objc_msgSend(); +} + +// --------------------------------------------------------------------------- +// Sel — a registered selector +// --------------------------------------------------------------------------- + +/// A registered Objective-C selector. +/// +/// Selectors are interned strings that identify a method name. Two selectors +/// with the same name are pointer-equal. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct Sel(NonNull); + +impl Sel { + /// Register (or look up) a selector by name. + /// + /// `name` must be a nul-terminated C string (use `c"foo:"` literals or + /// the `sel!` helper). + /// + /// # Panics + /// + /// Panics if the runtime returns null (should never happen for valid names). + pub fn register(name: &CStr) -> Sel { + // SAFETY: sel_registerName is thread-safe and always returns a valid + // pointer for well-formed selector names. + let ptr = unsafe { sel_registerName(name.as_ptr()) }; + Sel(NonNull::new(ptr).expect("sel_registerName returned null")) + } + + /// Return the raw pointer to the selector. + #[inline] + pub fn as_ptr(self) -> *mut ObjcSelector { + self.0.as_ptr() + } +} + +impl fmt::Debug for Sel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // We can't easily get the selector name without sel_getName, + // so just print the pointer. + write!(f, "Sel({:?})", self.0) + } +} + +// SAFETY: Selectors are globally interned pointers; they're safe to send +// across threads. +unsafe impl Send for Sel {} +unsafe impl Sync for Sel {} + +// --------------------------------------------------------------------------- +// Class — a reference to an Obj-C class +// --------------------------------------------------------------------------- + +/// A reference to an Objective-C class. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct Class(NonNull); + +impl Class { + /// Look up a class by name. + /// + /// Returns `None` if no class with that name is registered. + pub fn get(name: &CStr) -> Option { + // SAFETY: objc_getClass is thread-safe; it returns null for unknown + // class names. + let ptr = unsafe { objc_getClass(name.as_ptr()) }; + NonNull::new(ptr).map(Class) + } + + /// Allocate a new class pair (a new class and its metaclass). + /// + /// `superclass` is the parent class; `name` is the new class name; + /// `extra_bytes` is usually 0. + /// + /// Returns `None` if a class with `name` already exists. + /// + /// You must call [`Class::register`] on the returned class after adding + /// methods and ivars. + pub fn allocate(superclass: Class, name: &CStr, extra_bytes: usize) -> Option { + // SAFETY: objc_allocateClassPair returns null if the name is already + // taken. The superclass pointer is valid because it came from a Class. + let ptr = + unsafe { objc_allocateClassPair(superclass.as_ptr(), name.as_ptr(), extra_bytes) }; + NonNull::new(ptr).map(Class) + } + + /// Register a class that was previously created with [`Class::allocate`]. + /// + /// After this call the class is usable and no further methods/ivars can be + /// added. + pub fn register(self) { + // SAFETY: the pointer is valid and the class has not been registered + // yet (caller invariant). + unsafe { objc_registerClassPair(self.as_ptr()) } + } + + /// Add a method to a class that has not yet been registered. + /// + /// `sel` is the method selector, `imp` is the implementation function + /// pointer, and `types` is the ObjC type encoding string. + /// + /// Returns `true` if the method was added, `false` if a method with that + /// selector already existed. + pub fn add_method(self, sel: Sel, imp: Imp, types: &CStr) -> bool { + // SAFETY: the class pointer is valid and not yet registered. + unsafe { class_addMethod(self.as_ptr(), sel.as_ptr(), imp, types.as_ptr()) } + } + + /// Get the name of this class. + pub fn name(self) -> &'static str { + // SAFETY: class_getName always returns a valid C string for a valid + // class pointer. + let c_str = unsafe { CStr::from_ptr(class_getName(self.as_ptr())) }; + c_str.to_str().unwrap_or("") + } + + /// Return the raw pointer to the class. + #[inline] + pub fn as_ptr(self) -> *mut ObjcClass { + self.0.as_ptr() + } +} + +impl fmt::Debug for Class { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Class(\"{}\")", self.name()) + } +} + +// SAFETY: ObjC classes are globally registered and immutable after +// registration; safe to share across threads. +unsafe impl Send for Class {} +unsafe impl Sync for Class {} + +// --------------------------------------------------------------------------- +// Id — a non-null pointer to an Obj-C object instance +// --------------------------------------------------------------------------- + +/// A non-null pointer to an Objective-C object. +/// +/// This is an *unowned* (raw) reference — no automatic retain/release. +/// The caller is responsible for memory management. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct Id(NonNull); + +impl Id { + /// Create an `Id` from a raw non-null pointer. + /// + /// # Safety + /// + /// The pointer must point to a valid Objective-C object. + #[inline] + pub unsafe fn from_ptr(ptr: NonNull) -> Id { + Id(ptr) + } + + /// Try to create an `Id` from a raw pointer that might be null. + /// + /// # Safety + /// + /// If non-null, the pointer must point to a valid Objective-C object. + #[inline] + pub unsafe fn from_raw(ptr: *mut ObjcObject) -> Option { + NonNull::new(ptr).map(Id) + } + + /// Return the raw pointer. + #[inline] + pub fn as_ptr(self) -> *mut ObjcObject { + self.0.as_ptr() + } + + /// Cast to `*mut T` for use as a function argument. + /// + /// # Safety + /// + /// The caller must ensure the object is actually of type `T`. + #[inline] + pub unsafe fn cast(self) -> *mut T { + self.0.as_ptr().cast() + } +} + +impl fmt::Debug for Id { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Id({:?})", self.0) + } +} + +// SAFETY: ObjC objects can be sent across threads (whether this is safe +// depends on the object, but at the pointer level it's fine). +unsafe impl Send for Id {} +unsafe impl Sync for Id {} + +// --------------------------------------------------------------------------- +// msg_send! macro +// --------------------------------------------------------------------------- + +/// Return the raw `objc_msgSend` function pointer. +/// +/// The caller must transmute this to the correct function signature for the +/// message being sent. The `msg_send!` macro does this automatically. +#[inline] +pub fn msg_send_fn() -> unsafe extern "C" fn() { + objc_msgSend +} + +/// Send an Objective-C message. +/// +/// # Syntax +/// +/// ```ignore +/// // No arguments, returns Id: +/// let obj: Id = msg_send![class.as_ptr(), alloc]; +/// +/// // With arguments, returns Id: +/// let obj: Id = msg_send![obj.as_ptr(), initWithFrame: rect]; +/// +/// // No return value (returns ()): +/// msg_send![obj.as_ptr(), release]; +/// ``` +/// +/// The first argument must be a `*mut T` (the receiver). The return type is +/// inferred from context. +/// +/// # Safety +/// +/// This is inherently unsafe: wrong selector, wrong argument types, or wrong +/// return type will cause undefined behavior. +#[macro_export] +macro_rules! msg_send { + // No arguments: msg_send![receiver, selector] + [$receiver:expr, $sel:ident] => {{ + let sel = $crate::objc::Sel::register(concat!(stringify!($sel), "\0").as_bytes() + .split_last().map(|(_, s)| unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked( + std::slice::from_raw_parts(s.as_ptr(), s.len() + 1) + ) }).unwrap()); + let func: unsafe extern "C" fn(*mut std::os::raw::c_void, *mut std::os::raw::c_void) -> _ + = unsafe { std::mem::transmute($crate::objc::msg_send_fn()) }; + unsafe { func($receiver as *mut std::os::raw::c_void, sel.as_ptr() as *mut std::os::raw::c_void) } + }}; + + // One argument: msg_send![receiver, selector: arg] + [$receiver:expr, $sel:ident : $arg:expr] => {{ + let sel_name = concat!(stringify!($sel), ":\0"); + let sel = $crate::objc::Sel::register(unsafe { + std::ffi::CStr::from_bytes_with_nul_unchecked(sel_name.as_bytes()) + }); + let func: unsafe extern "C" fn( + *mut std::os::raw::c_void, + *mut std::os::raw::c_void, + _, + ) -> _ = unsafe { std::mem::transmute($crate::objc::msg_send_fn()) }; + unsafe { + func( + $receiver as *mut std::os::raw::c_void, + sel.as_ptr() as *mut std::os::raw::c_void, + $arg, + ) + } + }}; + + // Two arguments: msg_send![receiver, sel1: arg1, sel2: arg2] + [$receiver:expr, $sel1:ident : $arg1:expr, $sel2:ident : $arg2:expr] => {{ + let sel_name = concat!(stringify!($sel1), ":", stringify!($sel2), ":\0"); + let sel = $crate::objc::Sel::register(unsafe { + std::ffi::CStr::from_bytes_with_nul_unchecked(sel_name.as_bytes()) + }); + let func: unsafe extern "C" fn( + *mut std::os::raw::c_void, + *mut std::os::raw::c_void, + _, + _, + ) -> _ = unsafe { std::mem::transmute($crate::objc::msg_send_fn()) }; + unsafe { + func( + $receiver as *mut std::os::raw::c_void, + sel.as_ptr() as *mut std::os::raw::c_void, + $arg1, + $arg2, + ) + } + }}; + + // Three arguments: msg_send![receiver, sel1: arg1, sel2: arg2, sel3: arg3] + [$receiver:expr, $sel1:ident : $arg1:expr, $sel2:ident : $arg2:expr, $sel3:ident : $arg3:expr] => {{ + let sel_name = concat!( + stringify!($sel1), ":", + stringify!($sel2), ":", + stringify!($sel3), ":\0" + ); + let sel = $crate::objc::Sel::register(unsafe { + std::ffi::CStr::from_bytes_with_nul_unchecked(sel_name.as_bytes()) + }); + let func: unsafe extern "C" fn( + *mut std::os::raw::c_void, + *mut std::os::raw::c_void, + _, + _, + _, + ) -> _ = unsafe { std::mem::transmute($crate::objc::msg_send_fn()) }; + unsafe { + func( + $receiver as *mut std::os::raw::c_void, + sel.as_ptr() as *mut std::os::raw::c_void, + $arg1, + $arg2, + $arg3, + ) + } + }}; + + // Four arguments + [$receiver:expr, $sel1:ident : $arg1:expr, $sel2:ident : $arg2:expr, $sel3:ident : $arg3:expr, $sel4:ident : $arg4:expr] => {{ + let sel_name = concat!( + stringify!($sel1), ":", + stringify!($sel2), ":", + stringify!($sel3), ":", + stringify!($sel4), ":\0" + ); + let sel = $crate::objc::Sel::register(unsafe { + std::ffi::CStr::from_bytes_with_nul_unchecked(sel_name.as_bytes()) + }); + let func: unsafe extern "C" fn( + *mut std::os::raw::c_void, + *mut std::os::raw::c_void, + _, + _, + _, + _, + ) -> _ = unsafe { std::mem::transmute($crate::objc::msg_send_fn()) }; + unsafe { + func( + $receiver as *mut std::os::raw::c_void, + sel.as_ptr() as *mut std::os::raw::c_void, + $arg1, + $arg2, + $arg3, + $arg4, + ) + } + }}; +} + +// --------------------------------------------------------------------------- +// Convenience: sel! macro +// --------------------------------------------------------------------------- + +/// Register a selector from a string literal. +/// +/// ```ignore +/// let sel = sel!("init"); +/// let sel = sel!("initWithFrame:"); +/// ``` +#[macro_export] +macro_rules! sel { + ($name:literal) => {{ + $crate::objc::Sel::register(unsafe { + std::ffi::CStr::from_bytes_with_nul_unchecked(concat!($name, "\0").as_bytes()) + }) + }}; +} + +/// Look up a class by name (string literal). +/// +/// Returns `Option`. +/// +/// ```ignore +/// let cls = class!("NSObject").unwrap(); +/// ``` +#[macro_export] +macro_rules! class { + ($name:literal) => {{ + $crate::objc::Class::get(unsafe { + std::ffi::CStr::from_bytes_with_nul_unchecked(concat!($name, "\0").as_bytes()) + }) + }}; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_nsobject_class() { + let cls = Class::get(c"NSObject").expect("NSObject class should exist"); + assert_eq!(cls.name(), "NSObject"); + } + + #[test] + fn get_nonexistent_class_returns_none() { + let cls = Class::get(c"ThisClassDoesNotExist12345"); + assert!(cls.is_none()); + } + + #[test] + fn register_selector() { + let sel1 = Sel::register(c"init"); + let sel2 = Sel::register(c"init"); + // Same selector name should return the same pointer. + assert_eq!(sel1, sel2); + } + + #[test] + fn different_selectors_differ() { + let sel1 = Sel::register(c"init"); + let sel2 = Sel::register(c"alloc"); + assert_ne!(sel1, sel2); + } + + #[test] + fn alloc_init_nsobject() { + let cls = Class::get(c"NSObject").unwrap(); + // alloc + let obj: *mut std::os::raw::c_void = msg_send![cls.as_ptr(), alloc]; + assert!(!obj.is_null()); + // init + let obj: *mut std::os::raw::c_void = msg_send![obj, init]; + assert!(!obj.is_null()); + // release + let _: *mut std::os::raw::c_void = msg_send![obj, release]; + } + + #[test] + fn class_macro_works() { + let cls = class!("NSObject").expect("NSObject should exist"); + assert_eq!(cls.name(), "NSObject"); + } + + #[test] + fn sel_macro_works() { + let sel = sel!("init"); + let sel2 = Sel::register(c"init"); + assert_eq!(sel, sel2); + } + + #[test] + fn allocate_and_register_custom_class() { + let superclass = Class::get(c"NSObject").unwrap(); + // Use a unique class name to avoid conflicts with other tests. + let new_cls = Class::allocate(superclass, c"WeTestCustomClass", 0) + .expect("should allocate new class"); + new_cls.register(); + // Verify we can look it up. + let found = Class::get(c"WeTestCustomClass").expect("custom class should be registered"); + assert_eq!(found.name(), "WeTestCustomClass"); + } + + #[test] + fn add_method_to_custom_class() { + let superclass = Class::get(c"NSObject").unwrap(); + let new_cls = Class::allocate(superclass, c"WeTestMethodClass", 0) + .expect("should allocate new class"); + + // Define a simple method that returns an integer. + extern "C" fn my_method( + _this: *mut std::os::raw::c_void, + _sel: *mut std::os::raw::c_void, + ) -> i64 { + 42 + } + + let sel = Sel::register(c"myMethod"); + // Type encoding: returns long long (q), takes id (@) and SEL (:) + let added = new_cls.add_method( + sel, + unsafe { std::mem::transmute::<*const (), Imp>(my_method as *const ()) }, + c"q@:", + ); + assert!(added); + + new_cls.register(); + + // Allocate an instance and call our method. + let cls = Class::get(c"WeTestMethodClass").unwrap(); + let obj: *mut std::os::raw::c_void = msg_send![cls.as_ptr(), alloc]; + let obj: *mut std::os::raw::c_void = msg_send![obj, init]; + let result: i64 = msg_send![obj, myMethod]; + assert_eq!(result, 42); + let _: *mut std::os::raw::c_void = msg_send![obj, release]; + } + + #[test] + fn msg_send_with_argument() { + let cls = Class::get(c"NSObject").unwrap(); + let obj: *mut std::os::raw::c_void = msg_send![cls.as_ptr(), alloc]; + let obj: *mut std::os::raw::c_void = msg_send![obj, init]; + + // respondsToSelector: takes a SEL, returns BOOL (i8 on AArch64). + let sel = Sel::register(c"init"); + let responds: bool = msg_send![obj, respondsToSelector: sel.as_ptr()]; + assert!(responds); + + let _: *mut std::os::raw::c_void = msg_send![obj, release]; + } + + #[test] + fn class_debug_format() { + let cls = Class::get(c"NSObject").unwrap(); + let debug = format!("{:?}", cls); + assert!(debug.contains("NSObject")); + } + + #[test] + fn sel_debug_format() { + let sel = Sel::register(c"init"); + let debug = format!("{:?}", sel); + assert!(debug.contains("Sel(")); + } +} -- 2.51.2