Plugin Architecture in Swift(ish)

I have a strange thing with plugins. Every application which has plugin support is much better from the one that does not, even if the core functionality is smaller. It's about the potential of community being able to tweak and bend the application to their own needs. It's partially a reason why I tried to implement plugin architecture in most languages and technologies I worked with. Now it's time for Swift.

Luckily Cocoa has already a plugin architecture in place, it's just a matter of using that functionality in your own app. The architecture uses bundles, which connects code and resources into one package. Your application is one of those bundles and you should be used to interact with its resources using NSBundle.mainBundle() object. But it's much more powerful mechanism, which allows to dynamically load and use code, which is available in another bundle.

To create simplest plugin architecture we need a contract between host application and every plugin. They will be compiled independently, potentially by different developers, so we need to agree on how much they need to know about each other. A good construct to establish that contract is a protocol. We'll start with a very simple one:

// Plugin interface protocol

protocol PluginInterface
{
	var name: String { get }
	
	init()
}

Each plugin will have it's name, which we will be able to print after loading it the binary. We also add init() method, so we can create an object of it.

Humble Bundle

To create a plugin we need to create a Bundle target, which for now we can just as well add to the main app project:

With this target you'll get only an Info.plist file, which among few other things specifies a Principal class. The field is a name of a class, compiled in the bundle's binary, which will act as a communication interface between code from inside the bundle (in our case - the plugin) and outside of it (in our case - the host app). This string field maps to NSBundle's property called principalClass which returns AnyClass? type.

If you leave Principle class field empty or fill it with a string that doesn't map to any class it returns first from all classes compiled in the bundle. It's useful if you forget that the type name is actually SamplePlugin.SamplePlugin (or $(PRODUCT_MODULE_NAME).Class_Name in general), but if you're making a bundle with more complicated class structure it might be tricky to debug why principalClass is some other random class, not the one you thought you specified.

All that's left is to add the PluginInterface.swift file to newly created bundle and implement a class with that protocol

class SamplePlugin : PluginInterface
{
    var name = "SamplePlugin"
}

Everything builds successfully, for step 2/2 - loading it.

Lock and load

With NSBundle in our hands, it all boils down to locating a bundle - for now we'll use the folder the app is built:

let path = NSBundle.mainBundle().bundlePath

pluginHost.loadPluginsFromPath(path.stringByAppendingString("/../"))

And then loading a bundle, finding the principalClass and instantiating an object of that class.

// Loading Swift protocol from another bundle

if let bundle = NSBundle(URL: url) where bundle.load() {
	
	if let cls = bundle.principalClass as? PluginInterface.Type {
		let plugin = cls.init()

		plugins.append(plugin)
	}
}

There's just one problem here. The casting to PluginInterface.Type fails so we'll never get to initialize our object. That's the difference between dynamic Objective-C and type-safe Swift. Even though we're using the same protocol file, for Swift those are two different types. I tried few different approaches, using dynamicType field (initially suggested by Xcode), unsafeBitCasting, but at the end of the day I always saw unsatisfying nil as a result. I just couldn't convince Swift to trust me that the type in both bundles is the same (which kind of make sense).

With a little help from my friend

Does it mean it's impossible? Of course not! It works with Objective-C and I know few apps that though themselves are written in Objective-C, use plugins made in Swift.

First try was to mark the protocol using @objc. But provided the same outcome - casting resulted in nil. Even when the plugin protocol extended NSObjectProtocol, which shouldn't make any difference other than that it should act like a normal Objective-C protocol.

Surprisingly - doing just that - declaring the protocol in Objective-C itself and accessing it in Swift using bridging header made it work.

// Objective-C version of the protocol

@protocol PluginInterface <NSObject>

@property (nonatomic, copy, readonly) NSString *name;

@end 

Now since have a protocol in Objective-C, we have to modify the loading code slightly (and get init method "for free" from NSObject).

// Loading Swift class implementing Objective-C protocol

if let bundle = NSBundle(URL: url) where bundle.load() {
	
	if let cls = bundle.principalClass as? NSObject.Type,
		plugin = cls.init() as? PluginInterface {

		plugins.append(plugin)
                                
		print("> Plugin: \(plugin.name) loaded")
	}
}

And voila! We're getting the right object after casting to NSObject and PluginsInterface protocol from Objective-C bridge.

It's just a beginning

Now when code is properly loaded and we're able to interact with code from another bundle by both reading and "writing" to it. We can add actual functionality (even when it's a simple one).

// More Swift-friendly plugin protocol

@protocol PluginInterface <NSObject>

@property (nonatomic, copy, readonly) NSString * _Nonnull name;

- (NSString * _Nullable)convertString:(NSString * _Nullable)string;

@end

And with that new protocol we can build another simple plugin (in a separate Bundle target):

class Base64Plugin : NSObject, PluginInterface
{
    var name = "Text to Base64"

    func convertString(string: String?) -> String?
    {
        if let string = string,
            stringData = string.dataUsingEncoding(NSUTF8StringEncoding) {
                return stringData.base64EncodedStringWithOptions(.Encoding64CharacterLineLength)
        }

        return string
    }
}

I'll make an example out of you

To see it all in action, we just have to create a single window application, drag a NSTextView, add an NSMenu Plugins item and connecting them all with few lines of code:

// Loading and using plugins to manipulate strings

func applicationDidFinishLaunching(aNotification: NSNotification)
{
    let path = NSBundle.mainBundle().bundlePath
    
    pluginHost.loadPluginsFromPath(path.stringByAppendingString("/../"))
    
    pluginHost.plugins.forEach {
        let menuItem = NSMenuItem(title: $0.name, action: Selector("pluginItemClicked:"), keyEquivalent: "")
        menuItem.representedObject = $0
        
        pluginsMenu.addItem(menuItem)
    }
}

func pluginItemClicked(sender: NSMenuItem)
{
    let selectedRange = textView.selectedRange()
    
    if let plugin = sender.representedObject as? PluginInterface,
        string = textView.string as NSString? {
            
            let selectedString = string.substringWithRange(selectedRange)
            if let convertedString = plugin.convertString(selectedString) {
                textView.replaceCharactersInRange(selectedRange, withString: convertedString)
            }
            
    }
}

The code will load all bundles which are in the same directory as the app (again - for testing purposes - there are actually better places to load from), fill an menu bar menu with actions for each loaded plugin and use method from the protocol to manipulate selected string.

That's just a beginning but should be enough to get you started. I still want to investigate if there's a way to avoid using Objective-C bridging header, since that's one of the biggest annoyances for now. Maybe there's some bug in Swift, especially that using @objc didn't work. But that's a subject for another post.

For full example source code visit my github repository.

Want to talk?

comments powered by Disqus