Observing display reconfiguration changes on macOS. Moving from C function-pointer based API to a modern Swift AsyncStream.
Tom Lokhorst, Mathijs Kadijk
·3 min read
For our app CleanPresenter, we need to detect when new displays are added or removed in macOS. This happens when connecting a display via HDMI, but also when starting an AirPlay session to a TV, or using Sidecar with iPad.
There are two C functions for this in macOS, introduced 20 years ago in Mac OS X Panther (10.3):
/* A client-supplied callback function that’s invoked whenever the
configuration of a local display is changed. */typealias CGDisplayReconfigurationCallBack = (CGDirectDisplayID, CGDisplayChangeSummaryFlags, UnsafeMutableRawPointer?) -> Void/* Register a display reconfiguration callback procedure. The `userInfo'
argument is passed back to the callback procedure each time it is
invoked.
*/func CGDisplayRegisterReconfigurationCallback(
_ callback: CGDisplayReconfigurationCallBack?,
_ userInfo: UnsafeMutableRawPointer?
) -> CGError/* Remove a display reconfiguration callback procedure. */func CGDisplayRemoveReconfigurationCallback(
_ callback: CGDisplayReconfigurationCallBack?,
_ userInfo: UnsafeMutableRawPointer?
) -> CGError
These C functions do exactly what they promise, they callback the provided function, when a local display is reconfigured. However, we would like a more “Swifty” way of writing this code.
From functions to closures
The C functions for registering and removing a reconfiguration callback both take a function pointer and a userInfo pointer. Instead of dealing with a lone function pointer, we’d rather use a Swift closure.
let start = Date.nowlet closure = Closure2 { (displayID: CGDirectDisplayID, flags: CGDisplayChangeSummaryFlags) inprint("Reconfiguration, display:", displayID, start)
}
// Store closure in long-lived object, so that it doesn't go out of scopeself.stored = closure
CGDisplayRegisterReconfigurationCallback({ displayID, flags, userInfo inClosure2.invoke(displayID, flags, userInfo)
}, closure.pointer)
This shows how we close over the start variable. Note that we need to store the closure object somewhere, so that it isn’t clean up by ARC.
From closures to AsyncStream
Now that we can use closures, lets make this even more Swifty. We will create an AsyncStream that produces a new value each time a display reconfiguration happens.
var displayReconfigurations: AsyncStream<(CGDirectDisplayID, CGDisplayChangeSummaryFlags)> = AsyncStream { continuation in// Create closure that invokes continuationlet closure = Closure2 { (displayID: CGDirectDisplayID, flags: CGDisplayChangeSummaryFlags) in
continuation.yield((displayID, flags))
}
// A literal, that can be used as a C function pointerlet callback: CGDisplayReconfigurationCallBack = { (displayID, flags, userInfo) inClosure2.invoke(displayID, flags, userInfo)
}
// Remove registration when user terminates async stream
continuation.onTermination = { _ inCGDisplayRemoveReconfigurationCallback(callback, closure.pointer)
}
// Register callbackCGDisplayRegisterReconfigurationCallback(callback, closure.pointer)
}
This neatly wraps everything together, The closure is created that will yield new values to the async stream. The closure object itself, that we needed to keep alive, is stored in the onTermination handler, so that it remains as long as the user is iterating over the async loop.
This AsyncStream can be used like so:
let base = getBaseValue()
for await (displayID, flags) in displayReconfigurations {
print("Reconfiguration, display:", displayID, base)
}
With a bit of work, it's quite possible to move from 20 year old C function pointers, to a modern Swift API with an AsyncStream!