iOS Remote View (Controllers): A Brief Overview

Background

Back in iOS 6, Apple (privately) introduced remote view controllers. A few people, namely Ole Begemann, detailed how these worked, though no one seems to have figured out how to create their own remote view controller vendor services.

Fast-forward to 2013, when I was working on the (never to be released) jailbreak tweak Volt with @iCykey. For those who aren't aware, Volt was a tweak that allowed you to scroll through a number of widgets on your iPhone's lock screen. Since we were planning to allow third party widgets to be installed and used by Volt, I wanted a way to ensure bad widgets didn't end up crashing SpringBoard. This is when I discovered remote view controllers.

Remote view controllers did not exist in iOS 5. This was a problem for us, since we eventually wanted to support iOS 5 as it was used by a reasonably large portion of the jailbreaking user base at the time. Armed with the little knowledge I had in ARM assembly and reverse engineering, I set out to recreate Apple's remote view controller API in a way that was compatible with iOS 5.

Remotely rendering views

First off, I wanted to find out how the views themselves were being rendered across processes. It turns out that this is fairly simple. In QuartzCore, there is a class named CALayerHost that will render the contents of any window - providing you know the window's context ID. Apple's remote view controller implementation wraps this in a UIView for ease of use, so I figured I'd do the same. My implementation looks like so:

@implementation VTRemoteView

@dynamic contextId;

+ (Class)layerClass
{
    return [CALayerHost class];
}

- (id)initWithFrame:(CGRect)frame
{
    return [self initWithFrame:frame contextId:0];
}

- (id)initWithFrame:(CGRect)frame contextId:(unsigned int)contextId
{
    self = [super initWithFrame:frame];

    if (self) {
        CGFloat layerScale = 1.0f / [[UIScreen mainScreen] scale];

        CATransform3D translationTransform = CATransform3DMakeTranslation(-(frame.size.width / 2), -(frame.size.height / 2), 0.0f);
        CATransform3D scaleTransform = CATransform3DMakeScale(layerScale, layerScale, 1.0f);

        [[self layer] setTransform:CATransform3DConcat(translationTransform, scaleTransform)];
        [self setContextId:contextId];

        [self setUserInteractionEnabled:NO];
    }

    return self;
}

- (unsigned int)contextId
{
    return [(CALayerHost *)[self layer] contextId];
}

- (void)setContextId:(unsigned int)contextId
{
    [(CALayerHost *)[self layer] setContextId:contextId];
}

@end

Really, all we have to do is override +layerClass to make the view backed by a CALayerHost as opposed to a standard CALayer. Note that CALayerHost renders a pixel as a point, so on retina devices the rendered content ends up twice as large as it should be. To work around this I just scaled the layer by the inverse of the screen's scale factor.

If you just use this with an arbitrary UIWindow from an app, you'll notice something: your view seems to randomly not render its contents. This is because SpringBoard is the window manager. It is important to note that only one CALayerHost can render a window at any given time. As SpringBoard is already handling rendering of this window, you are simply unable to render it as well.

Fortunately, UIKit provides a way to tell SpringBoard not to handle rendering of a given window. We can simply subclass UIWindow and override its -_isWindowServerHostingManaged method to return NO in order to indicate that we want to handle hosting of this window ourselves. What's cool about this is that it can also be used to float windows on top of SpringBoard from within an app, providing that the app is constantly in the foreground. Since the window is no longer handled by SpringBoard, but drawn directly by backboardd, we need to lower the window level so that it does not overlap SpringBoard's main window. Apple uses a class named _UIHostedWindow that handles this, among other things. It simply sets the windowLevel to INT_MIN. This is trivial to reimplement:

@implementation VTSHostedWindow

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];

    if (self) {
        [self setKeepContextInBackground:YES];
        [self setWindowLevel:INT_MIN];
    }

    return self;
}

- (BOOL)_isWindowServerHostingManaged
{
    return NO;
}

- (BOOL)isInternalWindow
{   
    return YES;
}

#pragma mark - 6.x only.
- (BOOL)_canPromoteFromKeyWindowStack
{
    return NO;
}

- (BOOL)_usesWindowServerHitTesting
{
    return YES;
}

- (BOOL)_needsShakesWhenInactive
{
    return YES;
}

@end

That's about all there is to rendering the views themselves. Without further ado:

Considerations when implementing a vendor application

Launching the vendor application

This is where iOS' entitlements system gets in the way. In order to launch other apps in the background, your app needs to have the com.apple.backboardd.launchapplications entitlement. However, because we were loading our code into SpringBoard, we can't add this entitlement.

Luckily, there is a workaround: You can launch an app without any entitlements providing that the particular app in question is hidden. This is probably something you'll want to do anyway.

Add the following to your service app's Info.plist:

<key>SBAppTags</key>
<array>
    <string>hidden</string>
</array>

You can then launch the application with a call to SBSLaunchApplicationWithIdentifierAndLaunchOptions, making sure to launch it suspended.

However, we then need a way to bring the app into the "foreground" state without causing it to be the active app. Reversing Apple's remote view controller implementation, we can see it is done like so:

// Get the service's process identifier.
pid_t servicePid;
SBSProcessIDForDisplayIdentifier(CFSTR("foo.bar.remoteviewservice"), &servicePid);

if (servicePid != 0) {
    _processAssertion = [[BKSProcessAssertion alloc] initWithPID:servicePid
                                                           flags:(BKSProcessAssertionFlagPreventSuspend | BKSProcessAssertionFlagPreventThrottleDownCPU | BKSProcessAssertionFlagAllowIdleSleep | BKSProcessAssertionFlagWantsForegroundResourcePriority)
                                                          reason:BKSProcessAssertionReasonViewServices
                                                            name:@"foo.bar.remoteviewservice"
                                                     withHandler:^(BOOL success) {
                                                         if (success) {
                                                             // Set up ALL the things.
                                                         } else {
                                                            // Well this sucks.
                                                         }
                                                    }];
}

Refer to https://github.com/Cykey/ios-reversed-headers/blob/master/BackBoardServices/BackBoardServices.h for further documentation.

BackBoard doesn't actually exist on iOS 5, so you'll need to use a different method in that case. This is something I never got around to as we ultimately decided to forget about iOS 5 support.

Inter-process communication

For communication between the vendor application and the client application, I chose to use the XPC C API, purely because the Objective-C XPC API is not available on iOS 5. What's also nice about using XPC (which internally uses Mach ports), is that we can automatically bring the vendor service into the foreground when a client connects, and have it background when no clients are connected.

In order to get XPC to actually work, we need to let iOS know what mach ports the vendor app is going to use. To do this, you simply have to add an SBMachServices key containing an array of Mach service names (in this case your XPC service name) to your vendor application's Info.plist. For example:

<key>SBMachServices</key>
<array>
    <string>foo.bar.remoteviewservice.xpc</string
</array>

Windows, windows everywhere

This is where things can get a bit tricky. Certain UI components on iOS - such as the keyboard, text selection view, alert views & action sheets - are shown in their own windows. This presents a problem: How do we know which of these windows belong to which view controller?

In the case of the keyboard and text selection views, this is actually pretty straightforward. These windows are only created once and exist for the lifetime of the application. This means we can just pass the windows' context IDs to the client application and have it render them for us.

For action sheets, however, this is not the case: we need a way to notify the correct client when an action sheet's window is created. The way Apple do it is slightly...hacky...and is broken in some places (namely the AirPlay source selection action sheet). I chose do to it in the same way, since it is probably the only way.

By taking advantage of UIView's responder chain - which ends with the view controller managing the root view - we can hook UIActionSheet and do something like this:

%hook UIActionSheet

- (void)presentSheetInView:(UIView *)view
{
    %orig;

    UIResponder *responder = view;

    // Traverse the responder chain until we find an instance of VTSRemoteViewController.
    while ((responder = [responder nextResponder])) {
        if ([responder canPerformAction:@selector(presentRemoteActionSheet:inView:) withSender:self]) {
            [(VTSRemoteViewController *)responder presentRemoteActionSheet:self inView:view];

            break;
        }
    }
}

%end

Although the UIActionSheet itself has its own window, the view it is presented in will typically (but not always) reside in the view controller's subview tree.

Spoiler: If it's not, we're screwed.

That's it for now.