Native UIKit apps on Apple Watch

September 12 2015

Security

This is not a jailbreak. This will not make a jailbreak possible. This is no less secure than regular, sandboxed iOS apps. It’s not a way to bypass the security systems in place on your device. It does, however, let you build/prototype the kinds of apps you can do on iOS, but can’t on watchOS right now. Thought it important to say this up front 😜.

Update 21 Sep 2015

I’ve updated one of my open-source projects to use the methods here to build a UIKit watchOS 2 app. You can check it out here, for a concrete example of how to do this.

Preamble

Now that watchOS 2 and iOS 9 have GMed I figured it was a good time to write up one of the methods we came to for running ‘real’ native apps, i.e. ones that can use UIKit, on watchOS.

All of watchOS is based on the same frameworks we use on iOS - system apps use a framework that subclasses a bunch of UIKit apps, called PepperUICore. However, as developers, we’re not given the same kind of access to the system. We can’t use UIKit directly, or any high level graphics frameworks like OpenGLES, SpriteKit, SceneKit, CoreAnimation, etc. WatchKit apps are handcuffed, as it were, only able to display UI through the elements WatchKit provides. They cannot get the position of touches onscreen, or use swipe gestures, or multitouch.

I did not have an Apple Watch at launch, but I was convinced UIKit apps were possible with the existing SDKs. However, WWDC came and went, and still nobody had revealed a method to use UIKit on watchOS. I roped @b3ll into a torturous day & night of testing things, he in a cafe across from WWDC (with @saurik for much-needed moral support!), me across the Atlantic.

At the time, watchOS did not provide a system console log of any kind. It would provide crash reports, when it felt like it, and usually an hour after they happened. I had no hardware to test on here, so you can imagine how much pain & frustration this was. We were relying on the visual cues of different kind of app crashes to debug - endless spinner & black screen meant our code didn’t load, instant crash back to Carousel meant any one of three possible linker errors.

Eventually, a crashlog confirmed what I was hoping: watchOS was actually trying to load our modified binary, and our native code!

After getting something reproducible, and porting over some UIKit apps, I celebrated by ordering an Apple Watch of my own - I did want to see if the same method worked on watchOS 1, after all. With a few tweaks, it did!

Method

The method I stumbled upon is far from elegant, but it works across watchOS 1.x and watchOS 2.x. There are a half-dozen similar ways to do it on watchOS 2, but I haven’t seen others for watchOS 1 thus far. Objective-C used here to sidestep the complexity of embedded Swift libraries.

What follows is a brain dump of things necessary to get this working. I don’t have any easy ‘just download this sample project’ method right now. If you don’t understand Xcode targets, mach-o linking, and codesigning - turn back now! Xcode 7 (with WatchOS 2 SDK) required.

Key Elements

Desired Result

You want to end up with an iOS app, with a WatchKit 1 Extension, with an embedded WatchKit 1 App, with “MyApp.dylib” inside it. The WatchKit 1 App binary needs to have a linker reference to MyApp.dylib instead of SockPuppetGizmo (check this with otool -l), and the same goes for _WatchKitStub/WK inside the WatchKit 1 App bundle. You want to make sure each element is signed properly, too.

The steps are pretty similar for watchOS 2-only apps, though the layout on disk changes (you have to place your dylib in the WatchKit App’s embedded frameworks directory).

Missing Headers & Frameworks

watchOS is iOS; most of the frameworks you expect to be there are actually on-disk. However, the SDK will not include them, or have complete headers for them. Fortunately, Xcode 7 makes it ridiculously easy to link to frameworks through new text-based .tbd files that list all the symbols, and the supported architectures, for a framework. For the most part, you just need to copy the tbd files from the iPhoneOS SDK into the appropriate places in the WatchOS SDK, as well as their headers, then edit the tbd files to include ‘armv7k’. I’m sure somebody will automate this repetitive and thankless job 😜

Modified WatchKit Stub

The trick is to use install_name_tool to change the WatchKit’s stub’s dependency on SockPuppetGizmo (the framework that boots up WatchKit) to your framework. As this is a non-vital framework for a UIKit app, you don’t have to worry about it. Now, when the WatchKit stub app is loaded, instead of loading up WatchKit, it will load your framework/dylib.

N.B. apps that include a modified stub will not be accepted by iTunes Connect, so make sure to make a backup before changing it, and switch back to the original when you’re done with UIKit apps and need to actually ship things.

PLATFORMSDIR=`xcode-select -p`/Platforms
install_name_tool -change /System/Library/PrivateFrameworks/SockPuppetGizmo.framework/SockPuppetGizmo @rpath/MyApp.dylib $PLATFORMSDIR/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/Library/Application\ Support/WatchKit/WK

New in watchOS 2 - you cannot modify the stub’s rpath, but ./Frameworks works as an existing library location, though the validator will now trip if you have an actual .framework bundle in there. On watchOS 1, you can modify the rpaths to include custom locations inside the WatchKit app bundle.

WatchOS Framework Target

Effectively, all your app’s code will be in a WatchOS framework target, instead of an app. Your resources can be added to the WatchKit app’s assets bundle, and accessed through regular APIs (imageNamed, etc).

Then, all you need to do is call UIApplicationMain in your constructor, like regular UIKit apps. You can use attribute((constructor)) or linker flags to achieve this.

void __attribute__((constructor)) injected_main()
{
    @autoreleasepool {
        UIApplicationMain(0, nil, @"UIApplication", @"NativeAppDelegate");
    }
}

You’ll want to take the compiled binary from your framework as a dynamic library (as per 'MyApp.dylib’).

Build Script

You might notice at this point that WatchKit app targets can’t have build scripts, according to Xcode. Here’s where a little knowledge of the xcodeproj format might help - you can add a build script to the iOS target, then hand-edit the pbxproj file to add the reference for that shell script to the WatchKit App target. Or you could manually piece together your app with dylib and sign it all manually. There are many less clunky ways to do this than mine, so this part I’ll leave up to you. It took hours of trial and error to get something that ‘worked’, and I’m still not happy with it.

Next Steps

If you want to start building native apps that look like system apps, you’ll want to class-dump PepperUICore and investigate some of the apps in the Simulator. PepperUICore classes are prefixed PUIC and are mostly subclasses of UIKit classes. Your root UIApplication subclass should be PUICApplication. To implement a Force Touch menu on a view controller, you override -canProvideActionController and -actionController. There’s also an ORBTapGestureRecognizer if you want direct usage of Force Touch. Writing native PepperUICore apps deserves its own post, sometime…

Misc Notes

watchOS can be so incredibly fickle when installing binaries. I’ve seen this with non-hacked regular WatchKit apps too, but you can get into a state where the OS refuses to install a binary and gives you random error messages. Sometimes rebooting fixes this, sometimes removing the app from your phone helps. Sometimes the exact same binary won’t install one minute, and works the next.

watchOS 1’s ABI is incompatible with Xcode 7’s compiler, for various reasons that I’m sure Apple want to keep to themselves. While you can build working apps with the toolchain, all kinds of things crash inexplicably. Ideally, unless you have specific reason to try this on watchOS 1, stick to watchOS 2.

Most importantly - SockPuppet apps have strict entitlements that disallow all kinds of things, like networking. URL loading will only work with local files. You can’t touch networking frameworks, or AirDrop, or seemingly anything of use. While that is a pain, all kinds of other frameworks work fine like OpenGLES, SpriteKit, SceneKit, UIKit, etc, so it lets you prototype things impossible with WatchKit apps. With luck, WatchKit apps will grow those features over time, and make hacks like this irrelevant.

Addendum

Adam has an addendum on his side of the process, and where he took it from here. Check it out! 🍕