Agent skill
macos-cocoa-objc
macOS Cocoa and Objective-C interop for Rust applications
Install this agent skill to your Project
npx add-skill https://github.com/johnlindquist/script-kit-next/tree/main/.opencode/skill/macos-cocoa-objc
SKILL.md
macOS Cocoa/Objective-C Interop
This skill covers using the cocoa and objc crates to interact with macOS AppKit/Cocoa APIs from Rust. Script-kit-gpui uses these extensively for window management, system integration, and native UI features.
Crate Overview
cocoa crate (v0.26.x)
- Purpose: Rust bindings to Cocoa/AppKit frameworks
- Deprecated: In favor of
objc2crates, but still widely used - Modules:
cocoa::appkit- NSApp, NSWindow, NSScreen, NSPasteboard, etc.cocoa::base-id,nil,YES,NO,BOOLcocoa::foundation- NSString, NSRect, NSPoint, NSSize, NSArraycocoa::quartzcore- Core Animation (CALayer, etc.)
objc crate (v0.2.x)
- Purpose: Low-level Objective-C runtime bindings
- Key macros:
msg_send!,sel!,class! - Modules:
objc::runtime- Class, Object, Sel, Method, objc_getClassobjc::declare- ClassDecl for creating Objective-C classesobjc::rc- autoreleasepool
Key Concepts
Objective-C Messaging
All Objective-C method calls use message sending. In Rust:
use objc::{msg_send, sel, sel_impl, class};
use cocoa::base::{id, nil};
unsafe {
// [NSApp sharedApplication]
let app: id = msg_send![class!(NSApplication), sharedApplication];
// [window setLevel:3]
let _: () = msg_send![window, setLevel: 3i64];
// [window frame] - returns NSRect
let frame: NSRect = msg_send![window, frame];
// [window isKeyWindow] - returns bool
let is_key: bool = msg_send![window, isKeyWindow];
}
Selectors
Selectors are method name identifiers:
use objc::{sel, sel_impl};
let sel_frame = sel!(frame);
let sel_set_level = sel!(setLevel:);
let sel_set_frame_display = sel!(setFrame:display:); // Multiple args
The id Type
id is a pointer to any Objective-C object (*mut objc::runtime::Object).
nilis the null pointer equivalent- Always check for null before using
Common AppKit Types
NSApplication (NSApp)
use cocoa::appkit::NSApp;
use cocoa::base::id;
unsafe {
let app: id = NSApp();
// Set activation policy (0=Regular, 1=Accessory, 2=Prohibited)
let _: () = msg_send![app, setActivationPolicy: 1i64];
// Check if active
let is_active: bool = msg_send![app, isActive];
// Get all windows
let windows: id = msg_send![app, windows];
let count: usize = msg_send![windows, count];
}
NSWindow
use cocoa::foundation::NSRect;
unsafe {
// Window levels (NSWindowLevel)
const NS_NORMAL_WINDOW_LEVEL: i64 = 0;
const NS_FLOATING_WINDOW_LEVEL: i64 = 3;
const NS_MODAL_PANEL_WINDOW_LEVEL: i64 = 8;
const NS_POP_UP_MENU_WINDOW_LEVEL: i64 = 101;
let _: () = msg_send![window, setLevel: NS_FLOATING_WINDOW_LEVEL];
// Collection behaviors (bitflags)
const MOVE_TO_ACTIVE_SPACE: u64 = 1 << 1; // 2
const FULL_SCREEN_AUXILIARY: u64 = 1 << 8; // 256
const CAN_JOIN_ALL_SPACES: u64 = 1;
const STATIONARY: u64 = 16;
const IGNORES_CYCLE: u64 = 64;
let current: u64 = msg_send![window, collectionBehavior];
let new_behavior = current | MOVE_TO_ACTIVE_SPACE | FULL_SCREEN_AUXILIARY;
let _: () = msg_send![window, setCollectionBehavior: new_behavior];
// Frame operations
let frame: NSRect = msg_send![window, frame];
let _: () = msg_send![window, setFrame:new_frame display:true];
// Visibility
let _: () = msg_send![window, orderFront: nil]; // Show
let _: () = msg_send![window, orderOut: nil]; // Hide
let _: () = msg_send![window, makeKeyAndOrderFront: nil];
// Properties
let _: () = msg_send![window, setMovable: false];
let _: () = msg_send![window, setOpaque: false];
let _: () = msg_send![window, setHasShadow: true];
let _: () = msg_send![window, setRestorable: false];
let _: () = msg_send![window, setIgnoresMouseEvents: true];
}
NSScreen
use cocoa::appkit::NSScreen;
unsafe {
// Get all screens
let screens: id = NSScreen::screens(nil);
let count: usize = msg_send![screens, count];
// Get main screen (primary display)
let main_screen: id = NSScreen::mainScreen(nil);
let frame: NSRect = msg_send![main_screen, frame];
let visible_frame: NSRect = msg_send![main_screen, visibleFrame];
}
NSPasteboard (Clipboard)
use cocoa::appkit::NSPasteboard;
unsafe {
let pasteboard: id = NSPasteboard::generalPasteboard(nil);
// Efficient change detection (no payload read)
let change_count: i64 = msg_send![pasteboard, changeCount];
}
NSWorkspace
unsafe {
let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace];
// Get frontmost app
let app: id = msg_send![workspace, frontmostApplication];
let menu_owner: id = msg_send![workspace, menuBarOwningApplication];
// App info
let bundle_id: id = msg_send![app, bundleIdentifier];
let name: id = msg_send![app, localizedName];
let pid: i32 = msg_send![app, processIdentifier];
}
NSColor
unsafe {
// System colors
let clear: id = msg_send![class!(NSColor), clearColor];
let window_bg: id = msg_send![class!(NSColor), windowBackgroundColor];
// Custom RGBA
let color: id = msg_send![class!(NSColor),
colorWithRed: 0.5f64
green: 0.5f64
blue: 0.5f64
alpha: 0.8f64
];
}
NSVisualEffectView (Vibrancy/Blur)
// Materials (NSVisualEffectMaterial)
const POPOVER: isize = 6;
const SIDEBAR: isize = 7;
const HUD_WINDOW: isize = 13;
// Blending modes
const BEHIND_WINDOW: isize = 0;
const WITHIN_WINDOW: isize = 1;
// States
const FOLLOWS_WINDOW: isize = 0;
const ACTIVE: isize = 1;
const INACTIVE: isize = 2;
unsafe {
let _: () = msg_send![effect_view, setMaterial: POPOVER];
let _: () = msg_send![effect_view, setBlendingMode: BEHIND_WINDOW];
let _: () = msg_send![effect_view, setState: FOLLOWS_WINDOW];
let _: () = msg_send![effect_view, setEmphasized: true];
}
NSAppearance
#[link(name = "AppKit", kind = "framework")]
extern "C" {
static NSAppearanceNameDarkAqua: id;
static NSAppearanceNameVibrantDark: id;
static NSAppearanceNameAqua: id;
static NSAppearanceNameVibrantLight: id;
}
unsafe {
let appearance: id = msg_send![
class!(NSAppearance),
appearanceNamed: NSAppearanceNameVibrantDark
];
let _: () = msg_send![window, setAppearance: appearance];
}
Usage in script-kit-gpui
Window Level and Floating Panels
// From src/platform.rs - configure as floating panel
pub fn configure_as_floating_panel() {
unsafe {
let window = get_main_window()?;
// Float above normal windows
let _: () = msg_send![window, setLevel: 3i64];
// Move to active space when shown
let current: u64 = msg_send![window, collectionBehavior];
let desired = current | 2 | 256; // MoveToActiveSpace | FullScreenAuxiliary
let _: () = msg_send![window, setCollectionBehavior: desired];
// Disable restoration
let _: () = msg_send![window, setRestorable: false];
}
}
HUD Windows (Click-Through Overlays)
// From src/hud_manager.rs
unsafe {
let _: () = msg_send![window, setLevel: 101i64]; // PopUpMenuLevel
// Behaviors for HUD
let behaviors: u64 = 1 | 16 | 64; // CanJoinAllSpaces | Stationary | IgnoresCycle
let _: () = msg_send![window, setCollectionBehavior: behaviors];
// Click-through for non-interactive HUDs
let _: () = msg_send![window, setIgnoresMouseEvents: true];
// Show without activating
let _: () = msg_send![window, orderFront: nil];
}
Vibrancy Material Configuration
// From src/platform.rs - match Raycast/Spotlight appearance
pub fn configure_window_vibrancy_material() {
unsafe {
// Force VibrantDark appearance
let appearance: id = msg_send![
class!(NSAppearance),
appearanceNamed: NSAppearanceNameVibrantDark
];
let _: () = msg_send![window, setAppearance: appearance];
// Window background for native border
let bg: id = msg_send![class!(NSColor), windowBackgroundColor];
let _: () = msg_send![window, setBackgroundColor: bg];
let _: () = msg_send![window, setOpaque: false];
let _: () = msg_send![window, setHasShadow: true];
}
}
Accessory App (No Dock Icon)
// From src/platform.rs - LSUIElement equivalent at runtime
pub fn configure_as_accessory_app() {
unsafe {
let app: id = NSApp();
// NSApplicationActivationPolicyAccessory = 1
let _: () = msg_send![app, setActivationPolicy: 1i64];
}
}
Unsafe Patterns
Basic Pattern
#[cfg(target_os = "macos")]
pub fn do_cocoa_thing() {
unsafe {
// All Cocoa calls are unsafe
}
}
#[cfg(not(target_os = "macos"))]
pub fn do_cocoa_thing() {
// No-op on other platforms
}
Null Checking
unsafe {
let window: id = msg_send![app, keyWindow];
if window.is_null() {
return None; // Handle gracefully
}
// Safe to use window
}
Type Annotations are Required
// WRONG - compiler can't infer return type
let result = msg_send![obj, someMethod];
// CORRECT - explicit type annotation
let result: id = msg_send![obj, someMethod];
let _: () = msg_send![obj, setFoo: bar]; // void returns need ()
Integer Types Matter
// NSInteger is i64 on 64-bit macOS
let _: () = msg_send![window, setLevel: 3i64];
// NSUInteger is u64
let behavior: u64 = msg_send![window, collectionBehavior];
// Some APIs use isize
let material: isize = msg_send![effect_view, material];
Memory Management
Autorelease Pools
Required when creating Objective-C objects on background threads:
use objc::rc::autoreleasepool;
std::thread::spawn(|| {
autoreleasepool(|| unsafe {
// Create NSStrings, etc. here
let string: id = msg_send![class!(NSString), stringWithUTF8String: "hello"];
// Objects are released when pool drains
});
});
When Pools are Needed
- Background threads without existing pool
- Notification callbacks
- Any code that creates many temporary Objective-C objects
Manual Retain/Release (Rare)
unsafe {
let obj: id = msg_send![class!(SomeClass), alloc];
let obj: id = msg_send![obj, init];
// obj has +1 retain count
let _: () = msg_send![obj, release]; // -1 retain count
}
Threading
Main Thread Requirement
CRITICAL: AppKit APIs (NSApp, NSWindow, NSScreen, etc.) are NOT thread-safe and MUST be called from the main thread.
#[cfg(target_os = "macos")]
fn debug_assert_main_thread() {
unsafe {
let is_main: bool = msg_send![class!(NSThread), isMainThread];
debug_assert!(
is_main,
"AppKit calls must run on the main thread"
);
}
}
pub fn some_appkit_function() {
debug_assert_main_thread();
unsafe {
// Safe to call AppKit APIs
}
}
Thread-Safe Wrappers
For storing window IDs across threads:
#[derive(Debug, Clone, Copy)]
struct WindowId(usize);
impl WindowId {
fn from_id(window: id) -> Self {
Self(window as usize)
}
fn to_id(self) -> id {
self.0 as id
}
}
// Safe because we only store the ID, not access the window
unsafe impl Send for WindowId {}
unsafe impl Sync for WindowId {}
Background Observers
For notification observers on background threads:
std::thread::spawn(|| {
setup_workspace_observer(); // Creates run loop
});
fn setup_workspace_observer() {
autoreleasepool(|| unsafe {
// Create observer class
// Register for notifications
// Run the run loop
});
}
Creating Objective-C Classes
For notification observers or delegates:
use objc::declare::ClassDecl;
use objc::runtime::{Class, Object, Sel};
unsafe {
let superclass = Class::get("NSObject").unwrap();
// Check if class already exists
let observer_class = if let Some(existing) = Class::get("MyObserver") {
existing
} else {
let mut decl = ClassDecl::new("MyObserver", superclass).unwrap();
// Add method
extern "C" fn handle_notification(
_this: &Object,
_sel: Sel,
notification: *mut Object,
) {
let _ = std::panic::catch_unwind(|| {
autoreleasepool(|| unsafe {
// Handle notification
});
});
}
decl.add_method(
sel!(handleNotification:),
handle_notification as extern "C" fn(&Object, Sel, *mut Object),
);
decl.register()
};
}
NSString Conversion
Rust String to NSString
unsafe fn rust_to_nsstring(s: &str) -> id {
let c_str = std::ffi::CString::new(s).unwrap();
msg_send![class!(NSString), stringWithUTF8String: c_str.as_ptr()]
}
NSString to Rust String
unsafe fn nsstring_to_rust(nsstring: id) -> Option<String> {
if nsstring.is_null() {
return None;
}
let c_str: *const std::os::raw::c_char = msg_send![nsstring, UTF8String];
if c_str.is_null() {
return None;
}
Some(std::ffi::CStr::from_ptr(c_str).to_string_lossy().into_owned())
}
Anti-patterns
Don't Use keyWindow During Startup
// WRONG - keyWindow may be nil during startup
let window: id = msg_send![app, keyWindow];
// CORRECT - use a window registry
let window = window_manager::get_main_window()?;
Don't Forget Return Type Annotations
// WRONG - won't compile
msg_send![window, setLevel: 3];
// CORRECT
let _: () = msg_send![window, setLevel: 3i64];
Don't Call AppKit from Background Threads
// WRONG - will crash or produce undefined behavior
std::thread::spawn(|| {
let app: id = NSApp(); // BAD!
});
// CORRECT - use main thread
cx.spawn(|mut cx| async move {
cx.update(|cx| {
// AppKit calls here are on main thread
});
});
Don't Ignore Platform Checks
// WRONG - won't compile on other platforms
use cocoa::base::id; // Only exists on macOS
// CORRECT
#[cfg(target_os = "macos")]
use cocoa::base::id;
#[cfg(target_os = "macos")]
pub fn macos_only_function() { ... }
#[cfg(not(target_os = "macos"))]
pub fn macos_only_function() {
// No-op or appropriate fallback
}
Don't Swizzle Without Checking
// WRONG - may swizzle multiple times
pub fn swizzle_method() {
// swizzle code
}
// CORRECT - use atomic flag
static SWIZZLE_DONE: AtomicBool = AtomicBool::new(false);
pub fn swizzle_method() {
if SWIZZLE_DONE.swap(true, Ordering::SeqCst) {
return; // Already done
}
// swizzle code
}
Coordinate System
macOS uses a bottom-left origin coordinate system, opposite of most UI frameworks:
/// Convert from AppKit (bottom-left origin) to top-left origin
fn flip_y(screen_height: f64, y: f64, height: f64) -> f64 {
screen_height - y - height
}
/// Get primary screen height for coordinate conversion
fn primary_screen_height() -> Option<f64> {
unsafe {
let screens: id = NSScreen::screens(nil);
if screens.is_null() { return None; }
let primary: id = msg_send![screens, objectAtIndex: 0usize];
if primary.is_null() { return None; }
let frame: NSRect = msg_send![primary, frame];
Some(frame.size.height)
}
}
Quick Reference
| Task | Code |
|---|---|
| Get app | NSApp() |
| Get window | msg_send![app, keyWindow] |
| Set level | msg_send![window, setLevel: 3i64] |
| Show window | msg_send![window, orderFront: nil] |
| Hide window | msg_send![window, orderOut: nil] |
| Get frame | msg_send![window, frame] -> NSRect |
| Is main thread | msg_send![class!(NSThread), isMainThread] -> bool |
| Null check | if obj.is_null() { return; } |
| Platform guard | #[cfg(target_os = "macos")] |
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
Generate Component Documentation
Based on existing docs styles and specific API implementations, and referencing same name stories, generate comprehensive documentation for the new component.
Generate Component Story
Generate a comprehensive story for a new component for as example.
new-component
How to write a new component of GPUI Component.
troubleshooting
Diagnose and fix common Script Kit issues. Use when the user reports bugs, crashes, missing features, or unexpected behavior in Script Kit GPUI.
script-authoring
Create and manage TypeScript scripts for Script Kit. Use when the user wants to write a new script, edit an existing script, or understand Script Kit's SDK and metadata system.
agents
Create mdflow-backed agent files for Script Kit. Use when the user wants to create AI agents, configure agent backends (Claude, Gemini, Codex), or manage agent metadata.
Didn't find tool you were looking for?