How to use macOS Specific API in macCatalyst Apps(and Vice Versa)
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
@mainstruct 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.
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 :)