Apple ExtensionFoundation/ExtensionKit 示例应用程序

文本转换器:扩展工具包示例应用

今年的WWDC引入了许多新的API,其中两个引起了我的注意:ExtensionFoundation和ExtensionKit。

文本转换器示例应用的屏幕截图

非常感谢@mattmassicotte的帮助

一段时间以来,我们已经能够为 Apple 的应用程序和操作系统开发扩展,但 Apple 从未为第三方应用程序提供原生方式来提供其他应用程序可以利用的自定义扩展点。

有了macOS上的ExtensionFoundation和ExtensionKit,现在我们可以了。

However, Apple’s documentation lacks crucial information on how to use these new APIs (), and there were no WWDC sessions or sample code available in the weeks following the keynote.FB10140097

Thanks to some trial and error, and some help from other developers, I was able to put together this sample code, demonstrating how one can use ExtensionFoundation/ExtensionKit to define custom extension points for their Mac apps.

What the ExtensionFoundation and ExtensionKit frameworks provide

First of all, it’s important to set clear expectations. ExtensionFoundation and ExtensionKit provide the underlying discovery, management, and declaration mechanism for your extensions. They do not provide the actual communication protocol that your app will be using to talk to its extensions.

What you get is a communication channel (via ) that you can then use to send messages back and forth between your app and its extensions. If you’re already used to XPC on macOS, then you’re going to find it familiar. It’s very similar to talking to a custom XPC service, agent, or daemon.NSXPCConnection

Declaring a custom extension point

The main thing that’s not explained in Apple’s documentation at the time of writing is how apps are supposed to declare their own extension points to the system.

Extension points are identifiers (usually in reverse-DNS format) that apps providing extension points expose to apps that want to create extensions for those extension points.

In order to declare a custom ExtensionKit extension point for your Mac app, you have to include an file (or multiple files, one per extension point) in your app’s bundle, under the folder..appextensionpointExtensions

This sample app has a file with the following contents:codes.rambo.experiment.TextTransformer.extension.appextensionpoint

<?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>codes.rambo.experiment.TextTransformer.extension</key>
    <dict>
        <key>EXPresentsUserInterface</key>
        <false/>
    </dict>
</dict>
</plist>

It’s a simple property list file listing the app’s extension point identifier () and, for this particular extension point, that extensions of this type do not present any user interface ( set to ).codes.rambo.experiment.TextTransformer.extensionEXPresentsUserInterfacefalse

The host app target is then configured with an additional build phase in Xcode that copies the file into the destination.Copy Files.appextensionpointExtensionKit Extensions

Implementing an extension for a custom extension point

This sample code also includes an app, which demonstrates how apps can implement extensions for custom extension points.ExtensionProvider

The target itself doesn’t do much, it only serves as a parent for the target, which is an extension that implements the custom extension point above.ExtensionProviderUppercase

To create a target for a custom extension point, you can pick the “Generic Extension” template from Xcode’s new target dialog.

Generic Extension target template screenshot

The target declares support for the custom extension point in its Info.plist by setting the corresponding identifier for the property:UppercaseEXAppExtensionAttributes.EXExtensionPointIdentifier

<key>EXExtensionPointIdentifier</key>
<string>codes.rambo.experiment.TextTransformer.extension</string>

Defining the API for extensions

Apps that wish to provide custom extension points that other developers can write extensions for are likely going to be providing some sort of library or SDK that clients can use.

This sample code emulates this in the form of , a Swift package that defines a protocol, which looks like this:TextTransformerSDKTextTransformExtension

/// Protocol implemented by text transform extensions.
///
/// You create a struct conforming to this protocol and implement the ``transform(_:)`` method
/// in order to perform the custom text transformation that your extension provides to the app.
public protocol TextTransformExtension: AppExtension {
    
    /// Transform the input string according to your extension's behavior
    /// - Parameter input: The text entered by the user in TextTransformer.
    /// - Returns: The output that should be shown to the user, or `nil` if the transformation failed.
    func transform(_ input: String) async -> String?
    
}

Apps that wish to provide extensions can then implement a type that conforms to the given extension protocol, like the extension does:Uppercase

import TextTransformerSDK

/// Sample extension that transforms the input into its uppercase representation.
@main
struct Uppercase: TextTransformExtension {
    typealias Configuration = TextTransformExtensionConfiguration<Uppercase>
    
    var configuration: Configuration { Configuration(self) }
    
    func transform(_ input: String) async -> String? {
        input.uppercased()
    }
    
    init() { }
}

Enabling extensions

Even with all of the above correctly set up, if you try to use the API to find extensions for your custom extension point, you’re likely going to get zero results.

That’s because every new extension for your extension point is disabled by default, and the system requires user interaction in order to enable the use of a newly discovered extension within your app.

In order to present the user with a UI that will let them enable/disable extensions for your app’s custom extension point, you can use EXAppExtensionBrowserViewController, which this sample app presents on first launch if it detects that no extensions are enabled, or when the user clicks the “Manage Extensions” button.

UI to manage extensions for TextTransformer

After extensions are enabled, they will then be returned in the async sequence you can subscribe to with AppExtensionIdentity.matching.

Communication between the app and its extensions

This is a bonus section, since the communication between an app and its extensions is implemented through XPC, which is outside the scope of this sample app.

The way I chose to implement the simple protocol used by TextTransformer was to define a that gets exposed over the .TextTransformerXPCProtocolNSXPCConnection

Here’s the protocol itself:

@_spi(TextTransformerSPI)
@objc public protocol TextTransformerXPCProtocol: NSObjectProtocol {
    func transform(input: String, reply: @escaping (String?) -> Void)
}

Note that even though the protocol is declared as , I don’t want clients of my fictional SDK to have to worry about its existence, since the entire XPC communication is abstracted away. However, I need to be able to expose this protocol to the TextTransformer app itself, hence the use of , which has a scary underscore in front of it, but is the perfect solution for this need (exposing a piece of API only to a specific client that “knows” about it).public@_spi

I then implemented a class that is used as the for the from the extension side:TextTransformerExtensionXPCServerexportedObjectNSXPCConnection

@objc final class TextTransformerExtensionXPCServer: NSObject, TextTransformerXPCProtocol {
    
    let implementation: any TextTransformExtension
    
    init(with implementation: some TextTransformExtension) {
        self.implementation = implementation
    }
    
    func transform(input: String, reply: @escaping (String?) -> Void) {
        Task {
            let result = await implementation.transform(input)
            await MainActor.run { reply(result) }
        }
    }
    
}

The glue is implemented in , which is the configuration type associated with the protocol.TextTransformExtensionConfigurationTextTransformExtension

From the point of view of an extension implementing the protocol, all they have to do is return an instance of for the property, as seen in the implementation above.TextTransformExtensionTextTransformExtensionConfigurationconfigurationUppercase

extension TextTransformExtensionConfiguration {
    /// You don't call this method, it is implemented by TextTransformerSDK and used internally by ExtensionKit.
    public func accept(connection: NSXPCConnection) -> Bool {
        connection.exportedInterface = NSXPCInterface(with: TextTransformerXPCProtocol.self)
        connection.exportedObject = server
        
        connection.resume()
        
        return true
    }
}

The counterpart for this is , which is implemented in the TextTransformer app target itself, since it’s not something that extension implementers have to use.TextTransformerExtensionXPCClient

When a request is made to perform a transformation to the text, creates a new AppExtensionProcess from the extension that the user has selected in the UI.TextTransformExtensionHost

From that process, it then instantiates a , which grabs a new handle from the and configures it to use the :TextTransformerExtensionXPCClientNSXPCConnectionAppExtensionProcessTextTransformerXPCProtocol

// TextTransformerExtensionXPCClient.swift

public func runOperation(with input: String) async throws -> String {
    // ...
    
    let connection = try process.makeXPCConnection()
    connection.remoteObjectInterface = NSXPCInterface(with: TextTransformerXPCProtocol.self)
    
    connection.resume()
    
    // ...
}

The rest is pretty much just grabbing the remote object proxy (an instance of something that implements ) and calling the method, which will cause the method to be called on the instance of that’s running in the app extension, which in turn calls the method defined by the protocol.TextTransformerXPCProtocoltransformTextTransformerExtensionXPCServertransformTextTransformExtension

If you’re not familiar with XPC, this may all seem really alien, but it’s not as complicated as it sounds.

UI extensions

With ExtensionKit, Mac apps can also define extension points that support extensions presenting their own user interface.

In this sample project, I’ve added another extension point, called , by following the same approach of adding the file, this time setting the property to :codes.rambo.experiment.TextTransformer.uiextension.appextensionpointEXPresentsUserInterfacetrue

<?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>codes.rambo.experiment.TextTransformer.uiextension</key>
    <dict>
        <key>EXPresentsUserInterface</key>
        <true/>
    </dict>
</dict>
</plist>

For this example, I decided to use the UI extension point to give extensions the ability to provide configuration options for the text transformations that they perform.

I’ve included another sample extension in the app called , which shuffles the input string, randomizing it. This extension offers an option to also uppercase the shuffled string, and this option can be toggled in a configuration UI provided by the extension:ExtensionProviderShuffleShuffle

Popover for configuring the Shuffle extension

The thing to keep in mind here is that the toggle you’re seeing in that popover is not being created in the TextTransformer app, what you’re seeing is a “portal” into the app extension itself, which is creating that view and controlling what happens to it, as well as responding to its events.Shuffle

The TextTransformer SDK provides a new protocol that extensions can conform to if they wish to provide a custom configuration UI:

/// Protocol implemented by text transform extensions that also provide a view for configuration options.
///
/// You create a struct conforming to this protocol and implement the ``transform(_:)`` method
/// in order to perform the custom text transformation that your extension provides to the app, just like the non-ui variant (``TextTransformExtension``).
///
/// Extensions also implement the ``body`` property, providing a scene with the user interface to configure
/// settings specific to the functionality of this extension.
public protocol TextTransformUIExtension: TextTransformExtension where Configuration == AppExtensionSceneConfiguration {
    
    associatedtype Body: TextTransformUIExtensionScene
    var body: Body { get }
    
}

Notice that inherits from , since the UI extension will be both providing the configuration UI, as well as performing the text transformation itself. This was just how I decided to do it for this sample project, but you may want to design the API differently depending on your extension point’s needs. For example, I could have named this other extension point something like “TextTransformConfigurationExtension”, which would be used to configure an extension’s options, but wouldn’t actually be providing any text transformation functionality.TextTransformUIExtensionTextTransformExtension

Implementing the extension now looks like this:Shuffle

import SwiftUI
import TextTransformerSDK

/// Sample extension that shuffles the input string and provides an "options" scene
/// with UI to toggle between also uppercasing the string when doing the shuffle.
@main
struct Shuffle: TextTransformUIExtension {
    init() { }
    
    var body: some TextTransformUIExtensionScene {
        TextTransformUIExtensionOptionsScene {
            ShuffleOptions()
        }
    }
    
    func transform(_ input: String) async -> String? {
        // ...
    }
}

struct ShuffleOptions: View {
    @AppStorage(Shuffle.uppercaseEnabledKey)
    private var uppercaseEnabled = false
    
    var body: some View {
        Toggle("Also Uppercase", isOn: $uppercaseEnabled)
    }    
}

Thanks to and the fact that the base protocol implements a for us, this looks pretty much like a standard SwiftUI app entry point.@mainAppExtensionstatic func main()

The implementation for can be seen below, it’s basically wrapping the view provided by the extension’s property in a , which comes from ExtensionKit. It uses a custom wrapper view type that ensures the contents are contained within a that uses the style, as well as enforcing a minimum frame size and padding, which would be a way to keep things consistent between extensions in a real app.TextTransformUIExtensionOptionsScenebodyPrimitiveAppExtensionSceneForm.grouped

/// Protocol implemented by scenes that can be used in `TextTransformUIExtension.body`.
public protocol TextTransformUIExtensionScene: AppExtensionScene {}

/// A concrete implementation of `TextTransformUIExtensionScene` that provides a form where the user can configure options for a given extension.
/// You return an instance of this scene type from the `TextTransformUIExtension.body` property.
///
/// The content of the scene is where you create your user interface, using SwiftUI.
/// Do not use any property wrappers that invalidate the view hierarchy directly in your extension, wrap your UI in a custom view type
/// and add any property wrappers to the view.
public struct TextTransformUIExtensionOptionsScene<Content>: TextTransformUIExtensionScene where Content: View {
    
    public init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }
    
    private let content: () -> Content
    
    public var body: some AppExtensionScene {
        PrimitiveAppExtensionScene(id: TextTransformUIExtensionOptionsSceneID) {
            TextTransformUIExtensionOptionsContainer(content: content)
        } onConnection: { connection in
            connection.resume()
            
            return true
        }
    }
}

The provided to the is a custom string. Your SDK can provide multiple types of scenes that extensions can use, and each scene type is identified by this string. Each scene can also have its own XPC connection, which could use a different protocol from the main connection that you’ve seen before. For this simple example, I just resume the connection and return true, without performing any communication over that channel.idPrimitiveAppExtensionScene

In TextTransformer itself, I’ve extended the so that it can provide a SwiftUI view for a given extension’s options scene:TextTransformExtensionHost

extension TextTransformExtensionHost {
    
    /// Returns a SwiftUI view for the extension's "options" scene.
    /// - Parameter appExtension: The extension  to get the options scene for (must be a UI extension).
    /// - Returns: A SwiftUI view that renders and controls the extension's options UI.
    func optionsView(for appExtension: TextTransformExtensionInfo) -> some View {
        return TextTransformerUIExtensionHostViewWrapper(appExtension: appExtension)
    }
    
}

To actually display the scene, ExtensionKit provides , which I have wrapped in an to make it easy to use from TextTransformer’s UI, which is all implemented in SwiftUI.EXHostViewControllerNSViewControllerRepresentable

Here’s how the is being configured in TextTransformer:EXHostViewController

// TextTransformExtensionHost.swift

// ...

// TextTransformerExtensionUIController

let identity: AppExtensionIdentity

init(with appExtension: TextTransformExtensionInfo) {
    // ...
}

// ...

private lazy var host: EXHostViewController = {
    let c = EXHostViewController()
    c.configuration = EXHostViewController.Configuration(appExtension: identity, sceneID: TextTransformUIExtensionOptionsSceneID)
    c.delegate = self
    c.placeholderView = NSHostingView(rootView: TextTransformUIExtensionPlaceholderView())
    return c
}()

Pretty simple. The instance of is then embedded into , which is then wrapped in an so that the app can display it in SwiftUI using a popover.EXHostViewControllerTextTransformerExtensionUIControllerNSViewControllerRepresentable

The delegate for has callbacks for XPC connection errors, and it also has a callback to configure the , which in my example is not doing anything other than resuming it and returning true.EXHostViewControllerNSXPCConnection

Comments and use cases

Plug-ins for software have been around for a really long time. Traditional ways of implementing plug-ins on macOS would typically involve the host app loading an untrusted bundle of code into its own address space, which can have serious impacts in performance, security, and reliability.

When Apple began introducing extensions into its operating systems back in the iOS 8 days, the approach was quite different. Extensions are completely separate processes that run within their own sandbox and can’t mess with the memory of the process that they’re loaded into.

The result is a system that protects user privacy and leads to a more reliable experience overall, since in general a poorly behaving extension can’t crash the app that’s trying to use it (but that largely depends on how the extension hosting is implemented).

Use cases for custom extension points on Mac apps include any idea that involves external developers augmenting our apps with code running at native speeds, with access to the full macOS SDK, while at the same time isolating that code from our apps, protecting the trust that users have in them.

If you have a Mac app that currently provides other ways for developers to write scripts or extensions for it, or if you have an idea for a type of app that could benefit from third-party extensions, I’d consider implementing them with ExtensionKit.

GitHub

点击跳转