At WWDC 2018 Apple gave us a ‘sneak peek’ at perhaps one of the most impactful developments on macOS since the transition to Mac OS X: UIKit apps running on the desktop. Today, I'm going to detail a special tool I built, called marzipanify, to get started with UIKit on the Mac early, and start the initial bringup of your iOS app on macOS.
💡 Don't miss the other posts in this series
-
Part two: Making Marzipan Apps Sing
-
Part three: Deeper Integration with Marzipan
Overview
UIKit on the desktop is not like traditional macOS frameworks; it has its own /System/iOSSupport
directory that houses a web of dependencies taken from the iOS Simulator — iOS variants of WebKit, MapKit, and more — and is compiled as if it were a whole other platform to macOS, called 'iOSMac'. We know iOSMac by another name, rumored since December of 2017: Marzipan.
How it works
There's another reason for the iosmac distinction: many of the frameworks underneath, née iOS Simulator, clash horrendously with the built-in macOS frameworks, thanks to a decade of divergence from OS X. Both iOS and macOS today share a UIFoundation framework to help support UIKit and AppKit and provide common code, but UIFoundation makes decisions at runtime based on which platform it's running on that affect everything from Interface Builder to text rendering. At its simplest level, this means that if you link AppKit into your iOSMac app, all manner of things will explode in your app. The iosmac
linker variant for iOSMac binaries explicitly prevents loading non-iosmac binaries and libraries into your code (unless they're whitelisted).
macOS has several new components to support UIKit apps. At the top level is your UIKit app, except it doesn't run directly. Its layers are hosted in another process, UIKitHostApp.xpc
, which provides the AppKit-based window chrome and bridge to Mac features like the menu bar and toolbar. Each UIKitHostApp is managed by a singular UIKitSystem
app, which acts as the window/process/event manager. UIKitSystem takes the place in which SpringBoard traditionally sits on iOS, acting as the shell UI for all iOSMac apps, but bridging management to macOS' own WindowServer instead of providing a homescreen of its own.
Altogether, it appears running modern UIKit on macOS is so much more complex than the more-obvious tack Chameleon took when it rewrote UIKit for the Mac all those years ago. It's not a virtual machine by any stretch, despite evolving from the iOS Simulator, but it certainly goes to great lengths to distance itself from how Mac apps traditionally work. Perhaps that is a function of its incomplete state, or perhaps it hints at a vision for a new baseline for all Mac apps.
So. Enough preamble. How do I build my own UIKit apps on macOS?
Security & Preparation
iOSMac apps follow the modern pattern with Apple technologies in that they are 'entitlement-gated'. This means that unless your app is signed with private entitlements from Apple, the system will refuse to launch it. Because of this, even developers hoping to experiment with building iOSMac apps early, to get a head-start on macOS 10.15's public API, are unable to build and run their code by default on macOS.
Fortunately, macOS, unlike iOS, has a system which allows you to completely disable security and bypass everything. While that sounds terrifying, don't forget that that's just how macOS worked by default a few OS releases ago. It's only in recent years that everything has been locked down tightly.
To disable security, you first need to disable System Integrity Protection from Recovery Mode, and then you add a boot argument to bypass Apple Mobile File Integrity. Once disabled, this is how your Mac will run by default. If you wish to re-enable it, you can do so in the same way.
In Recovery Mode, use Terminal:
csrutil disable
nvram boot-args="amfi_get_out_of_my_way=0x1"
Now you can reboot into macOS proper.
Getting up and running
At this point, you could absolutely set up a build environment for iOSMac apps, but it's an arduous and complex process.
In some form or other, you need to:
- Copy all the UIKit headers to the Mac version of UIKit
- Port the UIKit tbd (linker symbols definition file) to the Mac framework
- Set up a project in Xcode that links to the Mac version of UIKit in
/System/iOSSupport/PrivateFrameworks/UIKitCore.framework
- Create a custom linker script that passes the correct arguments to build an iosmac binary instead of a macosx binary
- (and probably a lot more)
My recommendation is simple: do not even think about it. Why? Because I have built a much, much better and safer way…
When learning about iOSMac and its similarity with the iOS Simulator, I had a hunch one would be able to make an iOS Simulator app run as an iOSMac app. I studied the built-in iOSMac apps on Mojave intently, and, from what I knew about the Mach-O binary format, I was pretty sure that I could perform the requisite changes statically on an existing binary. Enter marzipanify.
marzipanify
marzipanify is a tool I created to statically convert an iOS app built for the iOS Simulator to macOS. It means you can continue working on and building your existing iOS app from its existing project, using the existing iOS SDK, and just run the tool against the Simulator build to create a functioning Mac app. As a bonus, marzipanify will yell at you when you're linking against a framework or library that doesn't currently exist in the iOSMac runtime. It trivializes the process so you can focus on adapting your app rather than managing a build environment.
How does marzipanify work?
marzipanify does five important things:
- it repackages your iOS app in a format macOS will be happy with
- it redirects all your framework links to their places in the iOSMac runtime
- it rewrites the mach header of your binaries to change the flag that determines whether they are macosx or iosmac binaries
- it updates your
Info.plist
with the appropriate keys for iosmac apps - it re-signs your app with the private entitlement all iosmac apps require
It performs these steps recursively on your binary and its embedded frameworks.
Everything marzipanify does can be done by hand, too. Repackaging is just a lot of moving files around and creating the correct folder structure, redirecting libraries can be done with install_name_tool
, and modifying the mach header can be done with a disassembler and hex editor. Indeed, that is the process by which I gradually figured out the steps in the first place. If you look at the source code, much of marzipanify is calling out to other commandline tools to do the hard work.
Using marzipanify
The easy way, if your app and its dependencies target iOS 12.0 as the minimum OS:
marzipanify MyAppBuiltForSimulator.app
The more likely way, if your app needs to target an older iOS:
INJECT_MARZIPAN_GLUE=1 marzipanify MyAppBuiltForSimulator.app
If your app has everything it needs included in the iOSMac runtime in Mojave, you should now have an app you can double-click and run.
Whoa whoa whoa, back up a bit. INJECT_MARZIPAN_GLUE? Explain.
In iOS 12, Apple updated the default output of the Xcode linker to use a new load command called LC_BUILD_VERSION instead of the traditional LC_VERSION_MIN_IPHONEOS and LC_VERSION_MIN_MACOSX, to prep for an environment where a single binary can run on both iOS and macOS. All the iOSMac apps included in Mojave use this method, and it's what the system expects for all iOSMac apps. The catch is that apps targeting an OS before iOS 12 use the other load command, and it's not trivial to add a whole new load command to an existing binary.
To solve this problem in a simplistic way, marzipanify has a mode where it builds a library and injects it into your app at conversion time. This library uses interposing to trick dyld, the linker, into thinking the app is running as an iosmac binary even when it's still a macosx binary. It works! It's a hack! But clearly, there be dragons.
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <dlfcn.h>
int dyld_get_active_platform();
int my_dyld_get_active_platform()
{
return 6; // iosmac
}
typedef struct interpose_s { void *new_func; void *orig_func; } interpose_t;
static const interpose_t interposing_functions[] __attribute__ ((used, section("__DATA, __interpose"))) = {
{ (void *)my_dyld_get_active_platform, (void *)dyld_get_active_platform }
};
Tune-up
Depending on how complex your app is, at this point you could be looking at a CrashReporter window and scratching your head. The thing is, not every iOS framework exists in iOSMac yet. In fact, other than WebKit, MapKit and AVFoundation, you don't have a lot to work with in this first iteration of iOSMac. Even if all the frameworks neccessary for your app are there, you might be using a number of classes that are omitted or deprecated, like UIFeedbackGenerator, UIWebView, or MFComposeViewController. At this stage, your only option is to start #ifdef
ing out portions of your codebase.
With enough of your app's non-essential features disabled, with any luck you should be greeted by something onscreen, and with a little cleanup you may just have a working app. Louis D'hauwe's Pixure (sadly no longer available) is one such success. James Thomson was also able to port the iOS version of PCalc to macOS with marzipanify, much like Peter Steinberger did for PSPDFKit's viewer app.
Advanced Functionality
One very interesting benefit of how 'INJECT_MARZIPAN_GLUE' works in marzipanify is that you can customize the source code you want to inject into the target app. How is this useful? Well, it allows you to patch apps that you may not have the source code to. In fact, I used this mechanism to 'port' Apple's own Contacts app to macOS. While this probably isn't something you need to know about in your own work, personally I like to keep a separation between the crazy hacks and patches I might need for bringup of a particular app, and its clean, shipping iOS project.
Make it sing
When Apple announced UIKit on the Mac, they mentioned a list of features to make a UIKit app feel more at home on the desktop, with menus and toolbars, mouse events and more. However, you may have realized that these classes are not included in the iOS SDK, and thus can't be used in your iOS Simulator build. I will detail how you can use the new Mac-specific classes to make your app look much more like a Mac app in a future post.
Conclusion
For now, I hope I've given you a superficial overview of what marzipanify is and what it can do: it's a very simple tool to kickstart your UIKit app bringup on macOS months before the SDK becomes public. It's certainly not all you need to make a great UIKit Mac app, but it's enough to get you started and thinking about the changes and redesigns you might need to make to your app and its layout to prepare it for a future on the Mac.
There's a bright future for UIKit on the Mac, and I genuinely believe that the quick & dirty ports that we've seen so far (even from Apple) are only scratching the surface of what's possible. But for now, developers gotta develop, so onwards with your quick & dirty ports and start experimenting. As always, you can find me at @stroughtonsmith.