Created
February 4, 2026 20:34
-
-
Save peterc/9558dd9d53e4ef9923e4faa8dd0e7184 to your computer and use it in GitHub Desktop.
A native macOS app written in Ruby using FFI to call libobjc, etc.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # A macOS GUI app written in Ruby, calling libobjc directly via Fiddle. | |
| # No gems. No Objective-C. Just Ruby and the runtime. | |
| require "fiddle" | |
| OBJC = Fiddle.dlopen("/usr/lib/libobjc.A.dylib") | |
| APPKIT = Fiddle.dlopen("/System/Library/Frameworks/AppKit.framework/AppKit") | |
| # Core runtime functions | |
| ObjcGetClass = Fiddle::Function.new(OBJC["objc_getClass"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOIDP) | |
| SelRegisterName = Fiddle::Function.new(OBJC["sel_registerName"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOIDP) | |
| AllocClassPair = Fiddle::Function.new(OBJC["objc_allocateClassPair"], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_VOIDP) | |
| RegisterClassPair = Fiddle::Function.new(OBJC["objc_registerClassPair"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID) | |
| ClassAddMethod = Fiddle::Function.new(OBJC["class_addMethod"], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT) | |
| MSG_SEND = OBJC["objc_msgSend"] | |
| def cls(name) = ObjcGetClass.call(name) | |
| def sel(name) = SelRegisterName.call(name) | |
| # Build a correctly-typed objc_msgSend call each time. | |
| # types: array of Fiddle type constants for the method arguments (not including self/SEL). | |
| # ret: Fiddle type constant for the return value. | |
| def msg(obj, sel_name, *args, types: [], ret: Fiddle::TYPE_VOIDP) | |
| sig = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP] + types | |
| fn = Fiddle::Function.new(MSG_SEND, sig, ret) | |
| fn.call(obj, sel(sel_name), *args) | |
| end | |
| def nsstr(s) | |
| msg(cls("NSString"), "stringWithUTF8String:", s, types: [Fiddle::TYPE_VOIDP]) | |
| end | |
| def alloc_init(class_name) | |
| msg(msg(cls(class_name), "alloc"), "init") | |
| end | |
| # Convenience type constants | |
| T_PTR = Fiddle::TYPE_VOIDP | |
| T_LONG = Fiddle::TYPE_LONG | |
| T_ULONG = Fiddle::TYPE_ULONG | |
| T_DOUBLE = Fiddle::TYPE_DOUBLE | |
| T_BOOL = Fiddle::TYPE_INT | |
| T_VOID = Fiddle::TYPE_VOID | |
| # ---- App setup ---- | |
| app = msg(cls("NSApplication"), "sharedApplication") | |
| msg(app, "setActivationPolicy:", 0, types: [T_LONG], ret: T_VOID) | |
| # ---- Menu bar ---- | |
| menubar = alloc_init("NSMenu") | |
| app_menu_item = alloc_init("NSMenuItem") | |
| msg(menubar, "addItem:", app_menu_item, types: [T_PTR], ret: T_VOID) | |
| app_menu = alloc_init("NSMenu") | |
| quit_item = msg( | |
| msg(cls("NSMenuItem"), "alloc"), | |
| "initWithTitle:action:keyEquivalent:", | |
| nsstr("Quit"), sel("terminate:"), nsstr("q"), | |
| types: [T_PTR, T_PTR, T_PTR] | |
| ) | |
| msg(app_menu, "addItem:", quit_item, types: [T_PTR], ret: T_VOID) | |
| msg(app_menu_item, "setSubmenu:", app_menu, types: [T_PTR], ret: T_VOID) | |
| msg(app, "setMainMenu:", menubar, types: [T_PTR], ret: T_VOID) | |
| # ---- Delegate (so closing the window quits the app) ---- | |
| # Create a callback for applicationShouldTerminateAfterLastWindowClosed: | |
| should_terminate = Fiddle::Closure::BlockCaller.new( | |
| T_BOOL, [T_PTR, T_PTR, T_PTR] | |
| ) { |_self, _cmd, _sender| 1 } | |
| dc = AllocClassPair.call(cls("NSObject"), "AppDel", 0) | |
| ClassAddMethod.call(dc, sel("applicationShouldTerminateAfterLastWindowClosed:"), should_terminate, "B@:@") | |
| RegisterClassPair.call(dc) | |
| delegate = msg(msg(dc, "alloc"), "init") | |
| msg(app, "setDelegate:", delegate, types: [T_PTR], ret: T_VOID) | |
| # ---- Window ---- | |
| # initWithContentRect:styleMask:backing:defer: | |
| # CGRect is 4 doubles passed in registers on ARM64. | |
| window = msg( | |
| msg(cls("NSWindow"), "alloc"), | |
| "initWithContentRect:styleMask:backing:defer:", | |
| 200.0, 200.0, 500.0, 300.0, # CGRect as 4 doubles | |
| 15, # style mask (titled|closable|miniaturizable|resizable) | |
| 2, # backing (buffered) | |
| 0, # defer (NO) | |
| types: [T_DOUBLE, T_DOUBLE, T_DOUBLE, T_DOUBLE, T_ULONG, T_ULONG, T_BOOL] | |
| ) | |
| msg(window, "setTitle:", nsstr("Hello from Ruby + libobjc"), types: [T_PTR], ret: T_VOID) | |
| # ---- Text label ---- | |
| label = msg( | |
| msg(cls("NSTextField"), "alloc"), | |
| "initWithFrame:", | |
| 50.0, 100.0, 400.0, 80.0, | |
| types: [T_DOUBLE, T_DOUBLE, T_DOUBLE, T_DOUBLE] | |
| ) | |
| msg(label, "setStringValue:", nsstr("Hello, World!\nFrom Ruby, via Fiddle, into libobjc.\nNo gems. No Objective-C."), types: [T_PTR], ret: T_VOID) | |
| msg(label, "setBezeled:", 0, types: [T_BOOL], ret: T_VOID) | |
| msg(label, "setDrawsBackground:", 0, types: [T_BOOL], ret: T_VOID) | |
| msg(label, "setEditable:", 0, types: [T_BOOL], ret: T_VOID) | |
| msg(label, "setSelectable:", 0, types: [T_BOOL], ret: T_VOID) | |
| font = msg(cls("NSFont"), "systemFontOfSize:", 24.0, types: [T_DOUBLE]) | |
| msg(label, "setFont:", font, types: [T_PTR], ret: T_VOID) | |
| content_view = msg(window, "contentView") | |
| msg(content_view, "addSubview:", label, types: [T_PTR], ret: T_VOID) | |
| # ---- Show and run ---- | |
| msg(window, "makeKeyAndOrderFront:", 0, types: [T_PTR], ret: T_VOID) | |
| msg(app, "activateIgnoringOtherApps:", 1, types: [T_BOOL], ret: T_VOID) | |
| msg(app, "run", ret: T_VOID) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment