Deeper Integration with Marzipan

March 07 2019

An advanced Marzipan app

Despite appearances, this is a UIKit app running on macOS Mojave via Marzipan using all the techniques referenced in prior posts. Additionally, it has a Mac-like icon and About window, provides Services, and supports AppleScript.

In this post I'm going to just superficially touch on some of the elements that make this possible, and some of the things you can look forward to when UIKit apps come to the Mac in macOS v10.15. Everything referenced here has come about from experimentation based on feedback from the last post.


💡 Don't miss the other posts in this series


Application Icon

App icon

You can turn off Asset Catalogs for your App Icon and provide a Mac icns file inside your bundle to use as your app icon. Set CFBundleIconFile in your Info.plist to its filename.

Credits

Credits window

Marzipan apps, just like AppKit apps, will pick up on a Credits.rtf or Credits.html file inside your app bundle, and use it to provide a credits section in the About window. No code required!

Services

Services are a longstanding feature of macOS and Mac apps.

From the HIG:

Services let people access functionality in one app from another. An app that provides services advertises the operations it can perform on particular types of data. The system then intelligently exposes its services in the app menu and in contextual menus that appear when Control-clicking text, files, and other kinds of data.

There is no high-level API for Services in Marzipan as of Mojave, so to make this work you need to delve deep into CFPasteboard and write your own mechanism to read a named pasteboard and return items from it. This is pretty gnarly code and I'm not going to document it here as it involves reverse-engineering and re-implementing NSPasteboard in AppKit. If you do want to try this yourself, talk to me.

After you have what you're looking for, you need to shuttle the data off to your chosen selector (passed as messageName) as defined in the NSServices section of your Info.plist.


Boolean MasterCallback(CFStringRef providerName, CFStringRef messageName, CFStringRef pasteboardName, CFStringRef userData, Boolean activate, CFStringRef *errorString)
{   
    /* Bring your own MyPasteboardReader class */
    NSData *data = [MyPasteboardReader dataForType:(NSString *)kUTTypeUTF8PlainText fromPasteboardWithName:(__bridge NSString *)(pasteboardName)];

     [(AppDelegate *)[UIApplication sharedApplication].delegate performSelector:NSSelectorFromString([NSString stringWithFormat:@"%@:",messageName]) withObject:data];

    return true;
}

-(void)setupServices
{
    CFServiceControllerRegisterProvider(kCFAllocatorDefault, MasterCallback);
}
<key>NSServices</key>
<array>
    <dict>
        <key>NSMenuItem</key>
        <dict>
            <key>default</key>
            <string>Render HTML in Browser</string>
        </dict>
        <key>NSMessage</key>
        <string>openSelection</string>
        <key>NSPortName</key>
        <string>Browser</string>
        <key>NSSendTypes</key>
        <array>
            <string>public.html</string>
            <string>com.apple.flat-rtfd</string>
            <string>public.rtf</string>
            <string>public.plain-text</string>
        </array>
    </dict>
</array>

📌 NSServices entry in Info.plist


AppleScript

AppleScript, similarly, has no-high level API that you can interact with from UIKit. If you want to implement it, you're going to have to go back to basics with the Apple Event Manager.

As with a scriptable AppKit app, you need to define NSAppleScriptEnabled in your Info.plist, and point OSAScriptingDefinition at a scripting definition file (.sdef).

At the very least, you're going to need a bunch of function prototypes and enums from the AE.framework headers to make this work (a part of CoreServices). To keep things simple, I redefine just the bits I need in my own code, which saves the complexity of adding Carbon and AE headers to your iOS SDK.

There is a private aeInstallRunLoopDispatcher() function in CoreServices which seems to be what you need to get Apple Events dispatched to the handlers in your app. After that, you can install and use your event handlers the same way you would have done before Cocoa.

One gotcha that tripped me up is that AppleScript uses NSUTF16LittleEndianStringEncoding for its strings, not UTF8.

void aeInstallRunLoopDispatcher(void);

OSErr myGetURLHandler(const AppleEvent *theAppleEvent, AppleEvent *reply, SRefCon handlerRefcon)
{   
    AEDesc eventObject;
    OSErr err;

    err = AEGetParamDesc(theAppleEvent, keyDirectObject, typeWildCard, &eventObject);
    if (err != noErr) {
        NSLog(@"Error decoding");
    }

    CFStringRef descRef = UTCreateStringForOSType(eventObject.descriptorType);
    NSString * descString = [NSString stringWithString:(__bridge NSString *)descRef];

    Size sz = AEGetDescDataSize(&eventObject);

    if (sz != 0)
    {
        char *r = malloc(sz);

        AEGetDescData(&eventObject, &r[0], sz);

        NSString *param = [[NSString alloc] initWithBytes:r length:sz encoding:NSUTF16LittleEndianStringEncoding];

        NSLog(@"Received parameter: %@", param);

        /* Do something with it! */
    }

    return noErr;
}

-(void)setupAppleEvents
{
    aeInstallRunLoopDispatcher();

    AEInstallEventHandler(kInternetEventClass, kAEGetURL, myGetURLHandler, NULL, false);
}
<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd">

<dictionary>
    <suite name="Internet suite" code="gurl" description="Standard terms for Internet scripting">
        <command name="open location" code="GURLGURL" description="Opens a URL">
            <direct-parameter type="text" optional="yes" description="the URL to open"/>
        </command>
    </suite>
</dictionary>

📌 ScriptableTasks.sdef


Conclusion

That's all for now, but I welcome questions you have about other core Mac technologies and their integration with UIKit apps on macOS. There have been a lot of Mac developers with fears as to what UIKit might mean for older technologies you know and love, and I'm curious to pop the hood and explore more areas of the OS that might be unfamiliar. You know where to find me.


P.S. — Marzipan.h

Getting started with a clean copy of the Marzipan-related headers can be a chore, especially if you're not familiar with class-dump, so I wrote a quick & dirty shell script to do all of the work for me.

This script relies on having a copy of class-dump in your $PATH, and what it does is dump all the headers from the system copy of UIKit to a temp folder, grab & clean-up just the parts you need, and puts it all in a single header called Marzipan.h for convenience.

You can then just import Marzipan.h and have access to all the Mac-specific API.

#!/bin/sh

SOURCE_DIR=/tmp/MZHeaders

CORE="UIHoverGestureRecognizer.h _UIContextualMenuGestureRecognizer.h _UILookupGestureRecognizer.h"
TOUCHBAR="_UITouchBar.h _UITouchBarController.h _UITouchBarItem.h _UIGroupTouchBarItem.h _UIButtonTouchBarItem.h"
MENUBAR="_UIMenuBarController.h _UIMenuBarItem.h _UIMenuBarMenu.h"
TOOLBAR="_UIWindowToolbarController.h _UIWindowToolbarItem.h _UIWindowToolbarButtonItem.h _UIWindowToolbarGroupItem.h _UIWindowToolbarLabelItem.h 
_UIWindowToolbarPopupButtonItem.h _UIWindowToolbarSearchFieldItem.h _UIWindowToolbarSegmentedControlItem.h _UIWindowToolbarShareItem.h"

mkdir -p $SOURCE_DIR
pushd $SOURCE_DIR
class-dump -tH -o . /System/iOSSupport/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore

echo "@protocol UHAMenuItemInterface, UHAMenuInterface, ROCKForwardingInterposableWithRunLoop, _UIValidatedUserInterfaceItem;" > Marzipan.h
echo "typedef void (^CDUnknownBlockType)(void);" >> Marzipan.h
cat $CORE >> Marzipan.h
cat $TOUCHBAR >> Marzipan.h
cat $MENUBAR >> Marzipan.h
cat $TOOLBAR >> Marzipan.h

echo "
extern int UITableViewStyleSidebar;" >> Marzipan.h

echo "
@interface UIWindow (MZToolbar)
- (_UIWindowToolbarController *)_windowToolbarController;
@end" >> Marzipan.h

echo "
@interface UIApplication (MZTouchBar)
@property(readonly, nonatomic, getter=_touchBarController) _UITouchBarController *touchBarController;
@end" >> Marzipan.h

echo "
extern NSString *_UIMenuBarStandardMenuIdentifierApplication;
extern NSString *_UIMenuBarStandardMenuIdentifierFile;
extern NSString *_UIMenuBarStandardMenuIdentifierEdit;
extern NSString *_UIMenuBarStandardMenuIdentifierView;
extern NSString *_UIMenuBarStandardMenuIdentifierWindow;
extern NSString *_UIMenuBarStandardMenuIdentifierHelp;" >> Marzipan.h

sed -i -e 's/- (void).cxx_destruct;//g' Marzipan.h
sed -i -e 's/#import <.*>//g' Marzipan.h

mv Marzipan.h $OLDPWD/
popd
rm -r $SOURCE_DIR