Yes, it is possible to write a Spotlight Importer entirely* in Swift!
*except for a few lines of code in main.m
I've just published one here: https://github.com/foxglove/MCAPSpotlightImporter
Here's a detailed blog post about the implementation process:
https://foxglove.dev/blog/implementing-a-macos-search-plugin-for-robotics-data
The difficult part of this is implementing a plugin that's compatible with the CFPlugIn architecture. (The MDImporter-specific logic is relatively minimal.) The CFPlugIn API is based on Microsoft's COM and Apple's docs are almost 20 years old.
The plugin is expected to be a block of memory conforming to a certain memory layout — specifically, the first value in the block must be a pointer to a virtual function table (vtable) for the requested interface (in the case of a MDImporter, this is either MDImporterInterfaceStruct
or MDImporterURLInterfaceStruct
) or the base IUnknown interface. This layout is documented here.
I wanted to organize the Swift code into a class, but you can't control the memory layout of a Swift class instance. So I created a "wrapper" block of memory which holds the vtable and an unsafe pointer to the class instance. The class has a static func allocate()
which uses UnsafeMutablePointer to allocate the wrapper block, create and store the class instance in it, and also initialize the vtable.
The vtable implements the standard COM base interface (IUnknown) functions (QueryInterface
, AddRef
, and Release
) by grabbing the class instance out of the wrapper and calling the queryInterface()
, addRef()
, and release()
methods on the instance. It also implements the Spotlight-specific ImporterImportURLData
function (or ImporterImportData
). Unfortunately, in my testing, it seemed like Spotlight did not pass the correct pointer to the wrapper struct as the first argument to ImporterImportURLData
, so it was impossible to call a method on the class instance, so the function that actually imports attributes for a file had to be a global function. For this reason I wasn't able to make the plug-in implementation a more generic class that could be used with any interface — it has to be tied to a specific global importer function.
I'd encourage you to view the full source on GitHub, but in the interest of not being a link-only answer, here's the core functionality:
final class ImporterPlugin {
typealias VTable = MDImporterURLInterfaceStruct
typealias Wrapper = (vtablePtr: UnsafeMutablePointer<VTable>, instance: UnsafeMutableRawPointer)
let wrapperPtr: UnsafeMutablePointer<Wrapper>
var refCount = 1
let factoryUUID: CFUUID
private init(wrapperPtr: UnsafeMutablePointer<Wrapper>, factoryUUID: CFUUID) {
self.wrapperPtr = wrapperPtr
self.factoryUUID = factoryUUID
CFPlugInAddInstanceForFactory(factoryUUID)
}
deinit {
let uuid = UUID(factoryUUID)
CFPlugInRemoveInstanceForFactory(factoryUUID)
}
static func fromWrapper(_ plugin: UnsafeMutableRawPointer?) -> Self? {
if let wrapper = plugin?.assumingMemoryBound(to: Wrapper.self) {
return Unmanaged<Self>.fromOpaque(wrapper.pointee.instance).takeUnretainedValue()
}
return nil
}
func queryInterface(uuid: UUID) -> UnsafeMutablePointer<Wrapper>? {
if uuid == kMDImporterURLInterfaceID || uuid == IUnknownUUID {
addRef()
return wrapperPtr
}
return nil
}
func addRef() {
precondition(refCount > 0)
refCount += 1
}
func release() {
precondition(refCount > 0)
refCount -= 1
if refCount == 0 {
Unmanaged<ImporterPlugin>.fromOpaque(wrapperPtr.pointee.instance).release()
wrapperPtr.pointee.vtablePtr.deinitialize(count: 1)
wrapperPtr.pointee.vtablePtr.deallocate()
wrapperPtr.deinitialize(count: 1)
wrapperPtr.deallocate()
}
}
static func allocate(factoryUUID: CFUUID) -> Self {
let wrapperPtr = UnsafeMutablePointer<Wrapper>.allocate(capacity: 1)
let vtablePtr = UnsafeMutablePointer<VTable>.allocate(capacity: 1)
let instance = Self(wrapperPtr: wrapperPtr, factoryUUID: factoryUUID)
let unmanaged = Unmanaged.passRetained(instance)
vtablePtr.initialize(to: VTable(
_reserved: nil,
QueryInterface: { wrapper, iid, outInterface in
if let instance = ImporterPlugin.fromWrapper(wrapper) {
if let interface = instance.queryInterface(uuid: UUID(iid)) {
outInterface?.pointee = UnsafeMutableRawPointer(interface)
return S_OK
}
}
outInterface?.pointee = nil
return HRESULT(bitPattern: 0x8000_0004) // E_NOINTERFACE <https://github.com/apple/swift/issues/61851>
},
AddRef: { wrapper in
if let instance = ImporterPlugin.fromWrapper(wrapper) {
instance.addRef()
}
return 0 // optional
},
Release: { wrapper in
if let instance = ImporterPlugin.fromWrapper(wrapper) {
instance.release()
}
return 0 // optional
},
ImporterImportURLData: { _, mutableAttributes, contentTypeUTI, url in
// Note: in practice, the first argument `wrapper` has the wrong value passed to it, so we can't use it here
guard let contentTypeUTI = contentTypeUTI as String?,
let url = url as URL?,
let mutableAttributes = mutableAttributes as NSMutableDictionary?
else {
return false
}
var attributes: [AnyHashable: Any] = mutableAttributes as NSDictionary as Dictionary
// Call custom global function to import attributes
let result = importAttributes(&attributes, forFileAt: url, contentTypeUTI: contentTypeUTI)
mutableAttributes.removeAllObjects()
mutableAttributes.addEntries(from: attributes)
return DarwinBoolean(result)
}
))
wrapperPtr.initialize(to: (vtablePtr: vtablePtr, instance: unmanaged.toOpaque()))
return instance
}
}
Finally, I created an @objc
class that exposes this allocate
function to Obj-C, where I can call it from main.m
, and return the pointer to the wrapper block from the factory function. This was necessary because I didn't want to use the unstable @_cdecl
attribute to expose a Swift function directly to the plug-in loader.
@objc public final class PluginFactory: NSObject {
@objc public static func createPlugin(ofType type: CFUUID, factoryUUID: CFUUID) -> UnsafeMutableRawPointer? {
if UUID(type) == kMDImporterTypeID {
return UnsafeMutableRawPointer(ImporterPlugin.allocate(factoryUUID: factoryUUID).wrapperPtr)
}
return nil
}
}
// main.m
void *MyImporterPluginFactory(CFAllocatorRef allocator, CFUUIDRef typeID) {
return [PluginFactory createPluginOfType:typeID factoryUUID:CFUUIDCreateFromString(NULL, CFSTR("your plugin factory uuid"))];
}
See my blog post for more details.