ExtensionKit Views

If you’re looking into ExtensionKit, it’s probably because of remote views. Hosting a view, managed entirely from a separate extension process, is a pretty incredible feature. Building and integrating view-based extensions is definitely more complex than the non-view case. But, the payoff is a very powerful and unique capability. So, let’s get to it!

This is part four of our series on ExtensionKit.

Host-Side Support

View-based extensions work the same as the non-view type. You discover them with AppExtensionIdentity. They still can have a global, per-extension XPC connection. You can still start them up with AppExtensionProcess. But, that part is optional.

Hosting the view can be done all on its own, with EXHostViewController. This is a simple NSViewController subclass. You can connect it up to an extension via its configuration property. That property takes two values: an AppExtensionIdentity and a scene name.

let hostViewController = EXHostViewController()

hostViewController.configuration = .init(appExtension: identity, sceneID: "default")

Right now, all the extension-hosting capabilities are macOS-only. But, every other part of ExtensionKit is supported by all Apple platforms.

Per-View Connections

In addition to the optional global connection, you can also make a connection to each view. Initially, I found this pretty confusing. But, it does make sense and is really convenient. Remember that an extension can be asked to instantiate any number of its views. Your app could have 10 windows, each of which contains its own remote view with its own separate state. A per-view connection gives you a way to interact with these individually.

To use view connections, you must make use of the EXHostViewController delegate.

extension MyViewController: EXHostViewControllerDelegate {
	func shouldAccept(_ connection: NSXPCConnection) -> Bool {
		return true
	}

	func hostViewControllerDidActivate(_ viewController: EXHostViewController) {
		let connection = try viewController.makeXPCConnection()

		// ...
	}
}

This looks similar to AppExtensionProcess, but it’s important that you wait for hostViewControllerDidActivate to be invoked.

UI Extension Template

Xcode 14’s “Generic Extension” template can also be used to support UIs. The resulting code has a lot going on. We’re not going to get into every part of it, but I’m including it here for reference. It’s ok to just skip it.

/// The AppExtensionScene protocol to which this extension's scenes will conform.
/// This is typically defined by the extension host in a framework.
public protocol ExampleAppExtensionScene: AppExtensionScene {}

/// An AppExtensionScene that this extension can provide.
/// This is typically defined by the extension host in a framework.
struct ExampleScene<Content: View>: ExampleAppExtensionScene {

	let sceneID = "example-scene"

	public init(content: @escaping () ->  Content) {
		self.content = content
	}

	private let content: () -> Content

	public var body: some AppExtensionScene {
		PrimitiveAppExtensionScene(id: sceneID) {
			content()
			} onConnection: { connection in
				// TODO: Configure the XPC connection and return true
				return false
			}
		}
	}

	/// The AppExtension protocol to which this extension will conform.
	/// This is typically defined by the extension host in a framework.
	protocol ExampleExtension : AppExtension {
		associatedtype Body: ExampleAppExtensionScene
		var body: Body { get }
	}

	/// The AppExtensionConfiguration that will be provided by this extension.
	/// This is typically defined by the extension host in a framework.
	struct ExampleConfiguration<E: ExampleExtension>: AppExtensionConfiguration {

		let appExtension: E

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

		/// Determine whether to accept the XPC connection from the host.
		func accept(connection: NSXPCConnection) -> Bool {
			// TODO: Configure the XPC connection and return true
			return false
		}
	}

	extension ExampleExtension {
		var configuration: AppExtensionSceneConfiguration {
			// Return your extension's configuration upon request.
			return AppExtensionSceneConfiguration(self.body, configuration: ExampleConfiguration(self))
		}
	}

	@main
	class ViewExtension: ExampleExtension {
		required init() {
			// Initialize your extension here.
		}
    
		var body: some ExampleAppExtensionScene {
			ExampleScene {
				Text("Hello, app extension!")
			}
		}
}

That’s a lot of code! It’s hard to see, but there is really just one core component. View-based extensions are all built around scenes, defined by conforming to AppExtensionScene. These are very similar to a SwiftUI Scene.

You use an AppExtensionConfiguration type to wire everything up, just like with a non-ui extension. But here we use AppExtensionSceneConfiguration, which takes your scene definitions, along with an optional AppExtensionConfiguration for the global connection.

A Minimal UI Extension

Now, this stock template includes a lot of protocols and generic types to help guide your structure. Unfortunately, I’ve found it incredibly difficult to keep that all straight. Before I suggest an alternative, let’s start by stripping it all down.

Here is a truly minimal implementation:

@main
final class ViewExtension: AppExtension {
	init() {
	}

	var configuration: AppExtensionSceneConfiguration {
		let scene = PrimitiveAppExtensionScene(id: "example-scene") {
			Text("Hello, app extension!")
			} onConnection: { connection in
				return false
			}
		}

		return AppExtensionSceneConfiguration(scene)
	}
}

To enable view support, you just need to use AppExtensionSceneConfiguration and configure a scene. The PrimitiveAppExtensionScene type is the basic building-block for an extension scene. And that onConnection callback is how you react to the host connecting.

Multiple Scenes

It took me a surprisingly long time to figure out how to configure more than one scene. You must use AppExtensionSceneBuilder. Again, this is very similar to regular SwiftUI Scenes.

Perhaps there is some API missing in this current beta, but I had to make a custom type using this builder to get multiple scenes to work.

public struct AppExtensionSceneGroup<Content: AppExtensionScene>: AppExtensionScene {
	private let content: Content

	public init(@AppExtensionSceneBuilder content: () -> Content) {
		self.content = content()
	}

	public var body: some AppExtensionScene {
		return content
	}
}

With this type, you can build up multiple scenes like this:

let scene = AppExtensionSceneGroup {
	PrimitiveAppExtensionScene(id: "one")
	PrimitiveAppExtensionScene(id: "two")
	PrimitiveAppExtensionScene(id: "three")
}

AppExtensionSceneConfiguration(scene)

You can find AppExtensionSceneGroup in Extendable.

Managing the Connection

The biggest challenge I’ve faced when working with view-based extensions is getting access to the connection in my views. Once the connection object is accessible to the view, you get it into the environment, manage it with ViewModels, or do whatever else you might want in a typical SwiftUI application. But, the structure of PrimitiveAppExtensionScene makes it tricky!

PrimitiveAppExtensionScene(id: "one") {
	// I need the connection here in the view!
} onConnection: { connection in
	// ... but how?
}

Now, I understand why PrimitiveAppExtensionScene was set up as it was. This connection isn’t just optional, it also might be established after the view is displayed. But, I found myself always wanting something more like this:

MyAppExtensionScene(sceneID: "one") { (connection: NSXPCConnection?) in
	// The view can now be constructed with access to the optional connection.
}

It took quite a bit of experimentation to get this working. But, I’m happy to report that it was possible, is open source, and is packaged up in Extendable.

A Less-Minimal Example

Armed with this infrastructure, let’s look at a little more full-featured implementation. We’ll get back to our GreetingProtocol and glue it all together.

First, we define a ViewModel that will manage the connection and implement our interface.

@MainActor
final class GreetingModel: ObservableObject {
	@Published var value: String

	private let connection: NSXPCConnection?

	init(connection: NSXPCConnection?) throws {
		self.connection = connection
		self.value = "model"

		if let connection {
			self.export(over: connection)
			connection.exportedObject = self
		}
	}
	
	var greeting: String {
		return "Hello \(value)!"
	}
}

extension GreetingModel: ExtensionProtocol {
	func greet(_ greetingParam: GreetingParameters) async throws -> Greeting {
		self.value = greetingParam.name

		return Greeting(greeting)
	}
}

If you are wondering why its init is marked throws, it’s to handle the situation where we want to reject a requested connection. This is unusual, so making the common case simpler makes sense.

Now, we can use this model in a simple view at the root of the scene.

struct GreetingView: View {
	@ObservedObject var model: GreetingModel

	init(connection: NSXPCConnection?) throws {
		self.model = try ConnectionModel(connection: connection)
	}

	var body: some View {
		Text("Greeting: \(model.greeting)")
	}
}

Finally, we’ll use that view in our AppExtension.

@main
final class ViewExtension: AppExtension {
	init() {
	}

	var configuration: AppExtensionSceneConfiguration {
		let scene = ConnectingAppExtensionScene(id: "one") { (_, connection) in
			try GreetingView(connection: connection)
		}

		return AppExtensionSceneConfiguration(scene)
	}
}

There’s very little ExtensionKit you need to work with. ConnectingAppExtensionScene helps to get you right into the more-familiar SwiftUI world quickly.

Going Further

Extendable includes another type that helps to slim down the boilerplate even further. Check out this version using ConnectableSceneExtension.

@main
final class ViewExtension: ConnectableSceneExtension {
	init() {
	}

	func acceptConnection(_ connection: NSXPCConnection) throws {
		// handle global connection
	}

	var scene: some AppExtensionScene {
		AppExtensionSceneGroup {
			ConnectingAppExtensionScene(id: "one") { (_, connection) in
				try GreetingView(connection: connection)
			}
		}
	}
}

We’ve removed the need to manage the configuration. We’ve set ourselves up to define additional named scenes. And, it’s also now easier to access the global connection.

Check out that View!

The remote view capabilities of ExtensionKit are pretty cool. As far as I can tell, all of SwiftUI and AppKit is usable from them. They do behave a little differently from in-process views though. For one, it looks like as of Ventura Beta 6, ExtensionKit does not integrate with any of the accessibility systems. Remote views can also have an impact on window resizing performance, but from my experimenting it’s a little unpredictable.

I have definitely struggled to understand and use ExtensionKit’s view APIs and suggested structure. But, it’s also been really fun to take the things I’ve learned about what has worked for Chime’s remote view integrations and put them into Extendable. The extension-side stuff is even usable from iOS! Of course, it could very well be that this all changes before Ventura ships. But, for now I’ve found it makes things much easier, especially for views.

When I started writing about ExtensionKit, I just assumed it would be one post. But, between XPC, managing APIs, and working with views, it’s a really big topic. I think there’s a whole new chapter opening up for third-party app integrations, and I’m excited to see what everyone builds. I hope you’ve enjoyed the series so far. And, if you have questions or comments, I’d love to hear them!

Oh, and we’ve also got a bonus post coming. We’re going to use ExtensionKit in a real-world example, by adding support for a new language to Chime using ChimeKit. 😎

It’s now available! We’ve made a DocC tutorial on how we built chime-swift, to add Swift support to Chime.

Sat, Sep 03, 2022 - Matt Massicotte

Previous: ExtensionKit End-to-End