ExtensionKit End-to-End

Working with ExtensionKit means you’ll need to think about APIs more than you might be used to. Of course, ExtensionKit has an API that you use in your app and extensions. Then there’s all the XPC stuff you have to deal with. And, on top of all that, you’ll probably also want a higher-level API that your extension developers will use. It’s a lot! In this post, we’re going to look at using all these APIs to build an end-to-end example.

This is part three of our series on ExtensionKit.

Viewing Available Extensions

Before we get into creating extensions, there’s a little bit of administration we have to deal with. Extension-hosting applications must include a bit of UI. This is the browser that shows what extensions are available to your app and gives the user the ability to enable/disable them. This comes in the form of a standalone view controller: EXAppExtensionBrowserViewController. There’s no configuration required. You just have to find an appropriate spot in your app for its view.

We also have to publish the extension point identifiers our app supports. This is done with a plist file that has an appextensionpoint extension. And, that file must be put into the Extensions directory of the app bundle. Xcode 14 includes a pre-made destination for that location in its Copy Files Phase UI.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.yourcompany.Extension</key>
	<dict>
		<key>EXPresentsUserInterface</key>
		<false />
	</dict>
</dict>
</plist>

As far as I can tell, this process is all totally undocumented at the moment. I was fortunate enough to find an Apple engineer at WWDC that shared the information with me. But, I’m sure this is a temporary problem.

A Minimal Extension

Xcode 14 comes with a new macOS target template called “Generic Extension”. From the code that spits out, you can see there are two core protocols involved in making an extension: AppExtension and AppExtensionConfiguration. They must be used together to correctly set things up, and I found the structure pretty confusing.

Let’s define a few types that conform to these protocols to see how it all fits together. We’re going to continue with our Greeting design from the previous post.

public protocol ExtensionProtocol {
	func greet(_ greeting: GreetingParameters) async throws -> Greeting
}

public protocol MyAppExtension: ExtensionProtocol, AppExtension {
	var hostApp: HostProtocol? { get set }
}

I’ve chosen to separate the ExtensionProtocol API from the AppExtension type. This extra level of abstraction makes it possible for a type to conform to ExtensionProtocol but not actually be an extension. I’ve found this pattern useful for testing, and when wrangling all of the interface objects involved in communication. But, it certainly isn’t required and you might find another arrangement suits you better.

Did you notice that hostApp property? Don’t worry, we’ll get to that soon.

Extension Configuration

The next important bit of setting up an extension is managing a type that conforms to AppExtensionConfiguration. Here’s an implementation that fits in with our Greeting protocols.

struct MyAppExtensionConfiguration<Extension: MyAppExtension>: AppExtensionConfiguration {
	let appExtension: Extension

	init(_ appExtension: Extension) {
		self.appExtension = appExtension
	}

	func accept(connection: NSXPCConnection) -> Bool {
		appExtension.export(over: connection)
		appExtension.hostApp = RemoteHost(connection)

		connection.activate()

		return true
	}
}

The important part here is the accept(connection:) method. This is where you configure the XPC connection to the host. We’re using the helpers we defined in the last post to keep this pretty slim. And, you can see where we set the extension’s hostApp property. This is used for the extension-to-host communication path.

With all this, we can finally set up a real extension:

@main
class GreetingExtension: MyAppExtension {
	var hostApp: HostProtocol? {
		didSet {
			// connection has been established (or removed?) here
		}
	}

	required init() {
	}
}

extension MyAppExtension {
	var configuration: MyAppExtensionConfiguration<Self> {
		return MyAppExtensionConfiguration(self)
	}
}

Configuration Complexity

I’ll be honest - I don’t love this pattern. I find it very complex, and I can see only downsides from that additional complexity. It’s extremely awkward that extension creation is separated from connection configuration like this. As far as I can tell, the design enforces this strange in-between state where an extension exists, but doesn’t have a connection. And, an extension has to handle the situation where a host connection is unset.

I would much prefer if this was all gone and the required initializer for an extension was just:

public protocol AppExtension {
	init(connection: NSXPCConnection) throws
}

I’ve provided feedback asking for this interface, but I suspect this is the API we’ll have to live with when Ventura ships. If anyone out there comes up with a nicer way to handle initialization/connection-configuration, or at least a use-case for the current design, please share it with me!

A Simpler Extension-Side API

I was annoyed enough with the existing API that I looked into ways to improve the situation. Here’s an equivalent implementation to the above.

final class GreetingExtension: ExtensionProtocol, ConnectableExtension {
	var hostApp: HostProtocol?

	func acceptConnection(_ connection: NSXPCConnection) throws {
		self.hostApp = RemoteHost(connection)
		
		self.export(over: connection)
	}
}

That ConnectableExtension protocol removes the need for separate configuration and provides more convenient access to the connection. I’ve put this implementation into a Swift package called Extendable, in case you want to check it out.

An earlier version of this blog post proposed adding a new init method that accepted the connection. That approach helped to eliminate the in-between state where an extension is been created, but not connected. I still don’t like that hostApp is optional. But, you may very well want to run some setup on extension process start. And, a host isn’t required to make a global connection. So, I think this version strikes a better balance, given how ExtensionKit actually can be used.

Discovering Extensions

Oh, whew. That was a lot of work to get our extension all set up. But, we’re now finally ready to make use of them in the host application.

We now need to actually find extensions that match our advertised extension point identifiers. This is done using the AppExtensionIdentity APIs. All extensions that have been enabled by the user (via that EXAppExtensionBrowserViewController view) can be discovered using its matching(appExtensionPointIDs:) method.

Task {
	let identitiesSeq = try! AppExtensionIdentity.matching(appExtensionPointIDs: "com.yourcompany.Extension")

	for await identities in identitiesSeq {
		for identity in identities {
			installIdentity(identity)
		}
	}
}

Interestingly, this API requires the use of an AsyncSequence, and was my first real exposure to that system. No ExtensionKit for you Objective-C developers!

Connecting to an Extension

Armed with our AppExtensionIdentity, we can actually start the extension and connect to it. This is done using AppExtensionProcess, which models the process the extension runs in. This gives the hosting process control over the lifecycle of extensions, as well as access to the per-extension NSXPCConnection.

func installIdentity(_ identity: AppExtensionIdentity) {
	let config = AppExtensionProcess.Configuration(appExtensionIdentity: identity)
	let process = try await AppExtensionProcess(configuration: config)
	let connection = try process.makeXPCConnection()
}

If you’re following along with the patterns setup from the previous post, configuring the connection is straightforward.

exportedHost.export(over: connection)

let remoteExtension = RemoteExtension(connection: connection)

Task {
	let value = try await remoteExtension.greet(params)
	
	print(value)
}

We Built Something!

We’ve finally gotten somewhere, and it only took three posts! We covered how ExtensionKit works and how we can design a system with XPC. Now we’ve put it all together to build an extension and integrate it into an app. And after a little ranting, also presented an open source library that helps to simplify building an extension.

But, believe it or not, we’re not done. In our next installment, we’re going to get into one of the most interesting features of ExtensionKit - supporting remote views!

Fri, Aug 26, 2022 - Matt Massicotte

Previous: ExtensionKit and XPC
Next: ExtensionKit Views