Thu 10 June 2021

Cacao: Building macOS (and iOS) Apps in Rust

Building native macOS apps outside the normal Apple ecosystem is sometimes a not-so-obvious task. The shifting ecosystem of AppKit, Catalyst, and SwiftUI - coupled with documentation that's sometimes non-existent, or spanning multiple operating system versions that each have their own nuance - makes it tricky to know just what you need to tie into.

One language that I've felt hasn't had a great solution to this yet is Rust. I've released an initial version of Cacao, which is my effort to enable building macOS (and iOS, see below) apps in Rust. This post showcases a bit of what Cacao is, how it works, and includes some opinions on GUI work sprinkled throughout.

History, Rust and Objective-C

I should clarify something before going any further. When I say that Rust hasn't had a great solution to this yet, I mean that there hasn't been an elegant way to build a macOS app in Rust. There is a litany of prior work though - some of which Cacao uses.

  • The objc crate by SSheldon has been around for years now, and does a very good job of providing a way to bridge Rust with Objective-C. Cacao relies on this heavily for bridging with the Apple side of things.
  • Delisa Mason wrote about building macOS apps in Rust as far as back as 2016, and the examples repository still holds up.
  • The core-foundation crate is often passed around as the "way" to build macOS apps in Rust. As far as I know, this was originally built to help Mozilla and Servo. It's a good crate, but I feel it's not as simple, and doesn't expose certain patterns that help make a macOS app feel "right".
  • fruity and objrs both attempt to offer better interop with the Objective-C side of things. Neither is used by Cacao, but I felt they're worth mentioning as they both are decent reading anyway.

Each of these are great works on their own, but none of them contribute to what I feel is a "native" macOS API style in Rust.

Feeling Native

So, let's define native.

When you build a macOS app in the standard, conventional way (AppKit), your primary building block is your Application Delegate. This is effectively where you receive varying messages from the OS (notifications, lifecycle events), and then handle them accordingly. The above examples don't provide a simple way to do this, and feel very procedural. Trying to go against the grain on macOS development often becomes annoying very quickly, and thus I define native as being able to replicate the intended pattern of development.

Let's look at a basic Swift macOS window example. Note that we're doing this in a no-storyboards or interface-builder fashion, as that's what Rust equivalent code will be doing; it's entirely fair to say that this could be shorter if you're the type to use Interface Builder.

import Cocoa
import Foundation

class AppDelegate: NSObject, NSApplicationDelegate {
    var window: NSWindow?
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        let frame = CGRect(
            x: 10.0,
            y: 10.0,
            width: 400.0,
            height: 400.0
        )
        
        let win = NSWindow(contentRect: frame, styleMask: [
            .closable, .fullSizeContentView, .miniaturizable, .fullScreen, 
            .resizable, .unifiedTitleAndToolbar
        ], backing: .buffered, defer: true)
        
        win.title = "Hello world"
        win.makeKeyAndOrderFront(nil)
        window = win
    }
}

// Just for running the app without all the Xcode @main items.
let delegate = AppDelegate()
NSApplication.shared.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

All things considered, that's pretty succinct - even the Objective-C version wouldn't be much longer! What I take note of here is specifically the applicationDidFinishLaunching(_:) method: this is a delegate method, and is a prime example of what I wanted in Rust. With Cacao, I feel like it gets fairly close:

use cacao::macos::{App, AppDelegate};
use cacao::macos::window::Window;

#[derive(Default)]
struct BasicApp {
    window: Option<Window>
}

impl AppDelegate for BasicApp {
    fn did_finish_launching(&self) {
        let window = Window::default();
        window.set_minimum_content_size(400., 400.);
        window.set_title("Hello World!");
        window.show();

        self.window = Some(window);
    }
}

fn main() {
    App::new("com.hello.world", BasicApp::default()).run();
}

Here, we do roughly the same thing as our Swift example, albeit even more succinct due to being able to take advantage of Default in Rust. App wraps NSApplication, and marshals events and method calls over to the AppDelegate trait implementation. For macOS, most of the NSApplicationDelegate methods and events are supported - although I haven't gotten around to wrapping NSNotification and passing it in to some methods. Pull requests welcome. :)

From here, you could continue with Cacao, or defer to another framework of your choice and simply rely on Cacao for the "mac" pieces. In fact, each Cacao control exposes a public objc property, which you can lock on and message pass to directly. This enables usage of other Rust wrappers (e.g, Metal), so you're not locked in to any one thing about Cacao.

On Subclassing

Rust doesn't have a concept of a Class built in to the language, instead favoring more of a composition-based approach with trait implementations. Many existing GUI models are subclass-heavy, though, and working with them can feel a bit odd from the Rust side. gtk-rs provides some utilities for working with subclasses from the Rust side, but what if we could keep it composition-based?

Cacao supports this, in a sense. Most widget types can be declared one of two ways: stock, in which you can call into it and set your properties, and treat it like a normal control - or delegated, where you can provide a struct that implements a trait for a given widget. For example, the View type could be constructed this way:

let view = View::default();
// Customize your view

This works fine - you could build up a tree from here and slap it wherever you want. If we did it as a delegate pattern, it'd look like this:

#[derive(Debug)]
pub struct MyView {
    handle: Option<View>
}

impl ViewDelegate for MyView {
    fn did_load(&mut self, view: View) {
        // Customize your view
        self.handle = Some(view);
    }

    // You could implement further methods here for handling drop and drop, etc
}

// elsewhere...
let view = View::with(MyView::default());

Feels kind of familiar, right?

A friend of mine once told me over beers: "If you go more than one subclass deep, you've done something wrong". Over the years, I've come around to this point of view, and I feel this approach fits that mantra perfectly - it feels like a subclass, in that you have a place to localize the logic and control setup, implement event handlers, and so on - but you can't go further into subclass hell.

The State of Things

As I'm writing this, Cacao is 0.2.0. It would be 0.1.0, but crates.io has a bit of a squatting issue, so 0.1.0 didn't truly exist.

It currently supports enough widgets on macOS to be usable. You can check out the examples folder to see more.

iOS support... it's more of a demo, but there's nothing really blocking controls from being ported other than the time investment.

Who's this even for...?

I don't expect that anybody would say to themselves "alright, time to write/rewrite macOS apps in Rust". I would expect that Cacao would never be able to be as usable as the blessed layers and frameworks that can be found with Swift and Objective-C, and I don't see the crossover between Apple-ecosystem developers and Rust-developers needing GUI tools to be massive.

I do see Cacao being useful for some things, though:

Rust-based Utilities

I think it's conceivable that there are Rust-based utilities (scripts, etc) that could be useful with a GUI, but bridging is (frankly) boring work, and frustrating when it goes wrong. Cacao is useful in this scenario as it enables building a native macOS GUI without having to leave the Rust ecosystem.

Cross-Platform GUI Framework

My dream for a Rust GUI framework is simple:

  • Target only the big three (Windows, macOS, Gnome). Anything else can be community supported. Maybe iOS and/or Android, if the effort isn't too signficant.
  • Offering more than the basic controls makes the framework unmaintainable over time. Follow the web model and keep the kitchen sink barebones, even if it's annoying. Long term maintainability > a million widgets.
  • Always expose a native hook on each control for the cases where someone knows what they want. AKA, get out of the way when asked.
  • A declarative (non-JSX-ish) model. Basically, copy SwiftUI.

I think some combination of winsafe, gtk-rs, and cacao could achieve this. If it happens, I'm happy to repurpose alchemy for this and make it a community thing - it was my original attempt at building a Rust cross-platform GUI, until I was sidelined by life events.

On iOS

A cool thing about Cacao is that the approach can work on iOS (and, presumably, tvOS) too. While support is currently very alpha in comparison to macOS, you are able to build a proper iOS app and run it:

This implements the newer UIScene API, meaning you should have a modern app that works well across both iOS and iPad (and in particular supports the iPad multi-app capability).

Going Forwards

Part of why I wanted to push out 0.2.0 is for community usage. I'm curious to see how people like it, whether it sees adoption, and so on. The documentation isn't perfect by any means (I welcome pull requests on this, although I'll continue to work on it as well), but I'm hopeful that feedback drives it forward.

In the long (long) term, I think frameworks like this will fade into the background and become building blocks for something like a RustUI (akin to SwiftUI with AppKit/UIKit). This is fine, and I consider it a win if Cacao acts as a bridge between UI generations.

Ryan around the Web