How to use macOS Specific API in macCatalyst Apps(and Vice Versa)

Erik Hric
Geek Culture
Published in
5 min readApr 19, 2021

Mac Catalyst allows developers to quickly port their iOS apps to macOS. But migrating them without adding any mac specific functions would be no different than running iOS simulator.

Motivation

Home was one of the first apps that Apple ported from iOS. It lets you control homekit accessories from your mac. What will happen when you try to use HomeKit API in your macOS app?

You can imagine, that the same situation will occur in iOS when you try to use NSStatusItem.
In this article we’ll take a look on how to use iOS specific API and macOS specific functions at the same time. (If you don’t want follow step-by-step process you can find complete source at the end of this post.)

Project setup

First, we need to create a new iOS app project. We’ll use the following settings:

And enable mac support under Deployment Info in General tab of your project settings.

First thing we need to do is initialize communication between macOS and iOS part when the app launches. To do so, we’ll create a new class that will implement `UIApplicationDelegate and declare it in our main app struct.

import SwiftUI
@main
struct iOS2macApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene {
WindowGroup {
ContentView()
}
}
}

Now to the part where magic happens…

macOS bundle setup

To run macOS code inside our app we need to create a new target — macOS Bundle. I named it macOSBridge but you can choose whatever you like and leave the rest of the settings as default.

This will create a new folder with the name you have chosen in which you can find only one Info.plist file. The last row of this file is Principal class of this bundle, so lets create one! While creating a new class you’ll be presented with option to also create a new bridging header so make sure you agree to do that.

After we define principal class (MacOSBridge) we need add its name to Info.plist last row. Our class name would be defined like this
$(PRODUCT_MODULE_NAME).MacOSBridge

Prepare communication interfaces

In this example we’ll demonstrate bidirectional communication by creating NSStatusItem and reacting to its click on iOS side of the app. To do so, we need 2 interfaces defined as follows.

@objc(iOS2Mac)
public protocol
iOS2Mac: NSObjectProtocol {
init()
var iosListener: mac2iOS? { get set }
func createBarItem()
}
@objc(mac2iOS)
public protocol
mac2iOS: NSObjectProtocol {
func barItemClicked()
}

Our MacOSBridge class needs to implement iOS2Mac protocol.

Make sure that you define these 2 interfaces in files which have Target Memberships in both our targets — mac Bundle and iOS App.

Loading and using code from the bundle

It is neccessary to load the bundle and instantiate its principal class. In this article we’ll do it in AppDelegate but you can do it whereever it suits your chosen architecture. Manual loading after app did finish launching looka like this. We keep the reference to to iOS2Mac implementation to use it later and we set our AppDelegate class which implements mac2iOS protocol as a listener to macOS events.

var ios2mac: iOS2Mac?func application(_ application: UIApplication,
didFinishLaunchingWithOptions
launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
) -> Bool {
loadPlugin()
return true
}
func loadPlugin() {
let bundleFile = "macOSBridge.bundle"
guard let bundleURL = Bundle.main.builtInPlugInsURL?.appendingPathComponent(bundleFile),
let bundle = Bundle(url: bundleURL),
let pluginClass = bundle.principalClass as? iOS2Mac.Type
else { return }
ios2mac = pluginClass.init()
ios2mac?.iosListener = self //respond to mac events
}

Closing the loop

In our example we’ll pass reference to AppDelegate via SwiftUI‘s @EnvironmentObject. (Keep in mind that we do it like this to simplify the code for the sake of tutorial.) I’m sure you know where you want to place your call to the bridge. You can find Github link with complete code at the end of this article. Clicking the button will trigger createBarItem() that in our case looks like this:

let statusItem = NSStatusBar.system.statusItem(withLength:NSStatusItem.squareLength)func createBarItem() {
if let button = statusItem.button {
button.title = "🏠" //since we control home 🙂
button.target = self
button.action = #selector(MacOSBridge.statusItemClicked)
}
}
@objc func statusItemClicked() {
iosListener?.barItemClicked()
}

The final magic

As promised in the beginning we’ll close the loop by controling HomeKit device when the NSStatusItem is clicked. So let’s do it…

We’ll do a very easy thing = turn on the first device in our list. So lets start with storing devices with power control characteristic in a simple array.

func setUpHomeManager() {
mgr = HMHomeManager()
mgr?.delegate = self
}
func homeManagerDidUpdateHomes(_ manager: HMHomeManager) {
var supportedAccessories:[HMAccessory] = []
for home in manager.homes {
let powerControls = home.accessories.filter {
$0.services.contains {
$0.characteristics.contains {
$0.characteristicType == HMCharacteristicTypePowerState
}
}
}
supportedAccessories.append(contentsOf: powerControls)
}
allPowerControllableAccessories = supportedAccessories
}
}

Last piece of the puzzle is to write value 1 to the power characteristic of first accessory in the list. We do it in HMAccessory extension:

extension HMAccessory {func turnOn() {
_ = self.services.map {
$0.characteristics.map {
if ($0.characteristicType == HMCharacteristicTypePowerState) {
$0.writeValue(1) { (e) in
print("error while turning on the device \(e?.localizedDescription ?? "")")
}
}
}
}
}
}

Troubleshooting

A few error messages you might see along the road..

This app has crashed because it attempted to access privacy-sensitive data without a usage description.  The app's Info.plist must contain an NSHomeKitUsageDescription key with a string value explaining to the user how the app uses this data.

You know what to do ;) so do it in iOS App Info.plist

If you see the following error…

Sync operation failed with error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service on pid 0 named com.apple.homed.xpc" UserInfo={NSDebugDescription=connection to service on pid 0 named com.apple.homed.xpc}

… you forgot to add HomeKit Capability to your iOS target or denied permision to access home data.

Wrap up

Adding macOS specific controls and functions will elevate your ported App on another level. But sometimes there are API availability paradoxes like HomeKit that force you to use different setup for your project. I used this approach to build a touchbar app to control lights in my office and control my accessories from outside of my local network without iPad or HomePod as a Home hub.

My ’Room8' app (lights are visible in reflection)

I hope this workaround will help you bring your ideas to life. Full source code is available here. Give me a ⭐️ if you find this post helpful.
I wish you a good luck with your project :)

--

--