Most iOS developers have probably dealt with URL schemes and URL routing at some point during their career. This topic became even more important after the introduction of deep linking in iOS 9. Today we’re going to take a look at how this works on the Mac and create a small URL routing library.
If you’re familiar with URL schemes on iOS, skip to the next section. If you’re not, this is a brief overview of how it works:
You register a unique URL scheme for your app using the Info.plist file. Let’s call it
func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool
From there on it’s up to you to handle the received URL. You could parse the URL yourself or use one of the countless URL routing libraries that emerged during the years, my favorite being JLRoutes.
The Mac is a much older platform with tons of legacy APIs. Registering schemes works just like it does on iOS using the Info.plist, but receiving URLs is tricky. When I first took a look at this, I just assumed
After a quick StackOverflow search, I knew
NSAppleEventManager Provides a mechanism for registering handler routines for specific types of Apple events and dispatching events to those handlers.
In case you want to know more about this, you can read about Apple Events in Cocoa here. This article just covers what we need to know in order to route URLs on macOS.
func handleEvent(_ event: NSAppleEventDescriptor, with replyEvent: NSAppleEventDescriptor)
Notice that we receive events as
let keyword = AEKeyword(keyDirectObject)
let urlDescriptor = event.paramDescriptor(forKeyword: keyword)
let urlString = urlDescriptor?.stringValue
Now that we know how to receive URLs, it’s time to create a class which is able to receive URLs and actually handle them. A
While all the code is going to be written in Swift, the majority of my macOS projects are still Objective-C. Thus the Swift code I’m presenting here is intended to be compatible with Objective-C. We will not only use classes, as Swift structs are not compatible with Objective-C, but they will also all inherit from
The
public class Router: NSObject {
public static let global = Router()
public override init() {
super.init()
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(handleEvent(_:with:)), forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
}
deinit {
NSAppleEventManager.shared().removeEventHandler(forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
}
@objc
private func handleEvent(_ event: NSAppleEventDescriptor, with replyEvent: NSAppleEventDescriptor) {
guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue else { return }
guard let url = URL(string: urlString) else { return }
print(url)
}
}
This is the first implementation of our
Remember how I mentioned earlier that there can only be one handler per class/identifier combination? If we created two
Now, what should our URL routing actually look like? We want to run a certain
In order to match URLs, we’re going to need a
But hold on, what’s a
Imagine a
Let’s say you’re creating a social network app and want to add a route to view user profiles. Instead of registering paths for every user, which would be virtually impossible, you just register a route with a placeholder:
It’s possible you want to allow an unknown number of path components at the end of an URL because you’re not aware of all possible paths beforehand. In this case, you register a route with a wildcard at the end:
I previously mentioned I’m using JLRoutes on iOS. For our Mac library, I’m going to shamelessly copy Joel’s idea of having priorities. The idea behind priorities is pretty self-explanatory, so I’m just going to leave you with an implementation of the concepts described above:
public typealias RouteHandlerAction = (URL) -> Bool
public typealias RouteHandlerID = Int
open class RouteHandler: NSObject {
public let route: String
public let scheme: String?
public let priority: Int
public let action: RouteHandlerAction
var id: RouteHandlerID
public required init(route: String, scheme: String?, priority: Int, action: @escaping RouteHandlerAction) {
self.route = route
self.scheme = scheme
self.priority = priority
self.action = action
self.id = -1
super.init()
}
@available(*, unavailable)
public override init() {
fatalError()
}
public func handle(url: URL) -> Bool {
if let scheme = self.scheme {
guard scheme == url.scheme else { return false }
}
// … route matching
return self.action(url)
}
}
After making the default initializer unavailable, we provide a new designated initializer that allows us to set all required properties.
Yeah, I kind of sneaked this into my code. We’re going to use handler IDs later so users can identify handlers without storing them.
Our
public class Router: NSObject {
// …
private(set) var handlers: [RouteHandlerID: RouteHandler] = [:]
private var currentHandlerIndex = -1
@discardableResult
public func register(_ handler: RouteHandler) -> RouteHandlerID {
self.currentHandlerIndex += 1
let id = self.currentHandlerIndex
self.handlers[id] = handler
handler.id = id
return id
}
// …
}
We’ll store handlers in a simple dictionary and use their IDs as keys. Later on, this will allow us to unregister handlers super fast if we know their IDs. If you imagined some super fancy calculation happening to create IDs you were wrong — it’s as boring as it can get, an increasing integer.
Registering a handler using our current implementation looks like this:
let handler = RouteHandler(route: "/", scheme: nil, priority: 0) { url in
return true
}
Router.global.register(handler)
A bit cumbersome, right? We’re not even using default parameters! Let’s solve this by adding two convenience methods to our
public class Router: NSObject {
// …
@discardableResult
public func register(_ route: String, for scheme: String? = nil, priority: Int = 0, action: @escaping RouteHandlerAction) -> RouteHandlerID {
return self.register(RouteHandler(route: route, scheme: scheme, priority: priority, action: action))
}
@discardableResult
public func register(_ routes: [String], for scheme: String? = nil, priority: Int = 0, action: @escaping RouteHandlerAction) -> [RouteHandlerID] {
return routes.map { self.register($0, for: scheme, priority: priority, action: action) }
}
// …
}
With this in place, registration will be much cleaner:
Router.global.register("/") { url in
return true
}
Router.global.register(["/entrance", "/backdoor"], for: "secret") { url in
return true
}
The first example, just like the one above, will match any route at its root:
Now we got our fancy registration and everything set up, but none of our handlers will ever be called. To do so, we need to first sort all registered handlers by their priority in a descending order and then give each handler a chance to handle the URL:
public class Router: NSObject {
// …
@objc
private func handleEvent(_ event: NSAppleEventDescriptor, with replyEvent: NSAppleEventDescriptor) {
guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue else { return }
guard let url = URL(string: urlString) else { return }
let handlers = self.handlers.sorted {
return ($0.1.priority > $1.1.priority)
}
for (_, handler) in handlers {
guard handler.handle(url: url) else { continue }
break
}
}
// …
}
When sorting a dictionary, the result will be an array of tuples, each consisting of the key and value of an entry. In our concrete case, the return type is
After sorting our handlers, we iterate through them and let each handler try to handle the URL. As soon as we find a matching handler, we break the loop. The only issue now is that we don’t actually perform any URL parsing or route matching yet. If you look closely at the current code of our
To match routes with URLs, we need to parse incoming URLs and compare their components with the components of all routes. If we performed the parsing of the received URL inside our
public final class RoutingRequest: NSObject, NSCopying {
public let url: URL
public let scheme: String
public private(set) var parameters: [String: String?]?
public private(set) var wildcardComponents: URLComponents?
let pathComponents: [String]
let queryItems: [URLQueryItem]?
public init?(url: URL) {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil }
self.url = url
guard let scheme = components.scheme else { return nil }
self.scheme = scheme
components.moveHostToPath()
self.pathComponents = components.pathComponents
self.queryItems = components.queryItems
super.init()
}
// …
}
If you’re familiar with the
The
public final class RoutingRequest: NSObject, NSCopying {
// …
func fulfill(with route: String) -> Bool {
var parameters = [String: String?]()
for (index, component) in self.pathComponents.enumerated() {
// route parsing …
}
if let queryItems = self.queryItems {
for item in queryItems {
parameters[item.name] = (item.value?.characters.count != 0) ? item.value : nil
}
}
if parameters.count != 0 {
self.parameters = parameters
}
return true
}
// …
}
The
let url = URL(string: "scheme://view/someuser?referrer=otheruser")!
let request = RoutingRequest(url: url)!
_ = request.fulfill(with: "/view/:user")
This means we need to split the given route into its components, separated by a
Doesn’t this practically scream Enumeration?
enum RouteComponent {
case path(String)
case placeholder(String)
case wildcard
}
The
enum RouteComponent {
// …
static func components(of route: String) -> [RouteComponent] {
let pathComponents = route.pathComponents
var components = [RouteComponent]()
for (index, component) in pathComponents.enumerated() {
if component == "*", index == pathComponents.count-1 {
components.append(.wildcard)
break
}
if component.characters.first == ":" {
let parameterName = String(component.characters.dropFirst())
components.append(.placeholder(parameterName))
continue
}
components.append(.path(component))
}
return components
}
}
Now that we have our route parsing in place, we need to actually make use of it! To avoid unnecessary work, we should do this as soon as possible. Let’s refactor and parse our routes when instantiating our
open class RouteHandler: NSObject {
// …
let routeComponents: [RouteComponent]
public required init(route: String, scheme: String?, priority: Int, action: @escaping RouteHandlerAction) {
// …
self.routeComponents = RouteComponent.components(of: route)
super.init()
}
// …
}
Back to the
public final class RoutingRequest: NSObject, NSCopying {
// …
func fulfill(with routeComponents: [RouteComponent]) -> Bool {
var parameters = [String: String?]()
var remainingRouteComponents = routeComponents
pathLoop: for (index, component) in self.pathComponents.enumerated() {
guard routeComponents.count >= index+1 else { return false }
let routeComponent = routeComponents[index]
remainingRouteComponents.remove(at: 0)
switch routeComponent {
case .path(let name):
guard name == component else { return false }
case .placeholder(let name):
parameters[name] = component
case .wildcard:
let remainingPath = self.pathComponents[index...self.pathComponents.endIndex-1].joined(separator: "/")
self.wildcardComponents = URLComponents(string: remainingPath)
break pathLoop
}
}
if remainingRouteComponents.count > 0 {
guard remainingRouteComponents.count == 1, case .wildcard = remainingRouteComponents[0] else { return false }
}
// …
}
// …
}
We iterate through all path components of our URL. First, we need to make sure a route component exists at the index of our current path component. If there are more path components than route components, we fail (wildcards are an exception, we’ll get there in a second).
Using the
A
Matching a
Hold on, the matching isn’t done yet! We’re not handling the case of having more route components than path components. That’s where the
The first implementation of
public typealias RouteHandlerAction = (RoutingRequest) -> Bool
// …
open class RouteHandler: NSObject {
// …
public func handle(request: RoutingRequest) -> Bool {
if let scheme = self.scheme {
guard scheme == request.scheme else { return false }
}
let request = request.copy() as! RoutingRequest
guard request.fulfill(with: self.routeComponents) else { return false }
return self.action(request)
}
}
After matching the scheme, we make a copy of the request object. We need to do this because calling
If the request is fulfilled we pass it to our
The
public class Router: NSObject {
// …
@discardableResult
public func route(request: RoutingRequest) -> Bool {
let handlers = self.handlers.sorted {
return ($0.1.priority > $1.1.priority)
}
for (_, handler) in handlers {
guard handler.handle(request: request) else { continue }
return true
}
return false
}
@discardableResult
public func route(urlString: String) -> Bool {
guard let request = RoutingRequest(string: urlString) else { return false }
return self.route(request: request)
}
@discardableResult
public func route(url: URL) -> Bool {
guard let request = RoutingRequest(url: url) else { return false }
return self.route(request: request)
}
// …
}
The
public class Router: NSObject {
// …
@objc
private func handleEvent(_ event: NSAppleEventDescriptor, with replyEvent: NSAppleEventDescriptor) {
guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue else { return }
self.route(urlString: urlString)
}
// …
}
Looks good! We’re almost done, but…
Remember how I mentioned earlier that there can only be one handler per class/identifier combination?
We still can’t use multiple router instances at the same time! The easiest solution would be to make the initializer of
Due to this restriction of having only one handler for URL events, we will eventually need to introduce a singleton. However, we can refactor the event handling into a separate class and have a singleton there instead of in the
The
public final class URLEventHandler: NSObject {
public static let global = URLEventHandler()
private override init() {
super.init()
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(handleEvent(_:with:)), forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
}
deinit {
NSAppleEventManager.shared().removeEventHandler(forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
}
}
Objects which the
public protocol URLEventListener: class {
func handleURL(_ url: String)
}
Types that implement the
private struct WeakObject<T: AnyObject> {
private(set) weak var object: T?
init(object: T) {
self.object = object
}
}
Instead of storing our listeners directly, we wrap them inside a
public final class URLEventHandler: NSObject {
// …
private var listeners = [WeakObject<URLEventListener>]()
public func addListener(_ listener: URLEventListener) {
self.listeners.append(WeakObject(object: listener))
}
@discardableResult
public func removeListener(_ listener: URLEventListener) -> Bool {
guard let index = self.listeners.index(where: { $0.object === listener }) else { return false }
self.listeners.remove(at: index)
return true
}
}
Handling URL events pretty much looks like it did originally in the
public final class URLEventHandler: NSObject {
// …
@objc
private func handleEvent(_ event: NSAppleEventDescriptor, with replyEvent: NSAppleEventDescriptor) {
guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue else { return }
for listener in self.listeners {
listener.object?.handleURL(urlString)
}
}
// …
}
Speaking of the
public class Router: NSObject, URLEventListener {
// …
public override init() {
super.init()
URLEventHandler.global.addListener(self)
}
deinit {
URLEventHandler.global.removeListener(self)
}
public func handleURL(_ url: String) {
self.route(urlString: url)
}
// …
}
Now we can use multiple
This is a small library and 33 tests were enough to achieve this test coverage. There are a couple of edges cases which aren’t being covered by the tests as of the time of writing.
Testing is boring and there’s nothing spectacular here either…except for this one thing: how do you test
I wouldn’t know how to create a mock application that receives URL events by the system (possibly using
I encountered this charming method while looking at the documentation:
func dispatchRawAppleEvent(UnsafePointer<AppleEvent>, withRawReply: UnsafeMutablePointer<AppleEvent>, handlerRefCon: SRefCon)
The mysterious
Luckily
var url = …
let urlDescriptor = NSAppleEventDescriptor(applicationURL: url)
let event = NSAppleEventDescriptor(eventClass: AEEventClass(kInternetEventClass), eventID: AEEventID(kAEGetURL), targetDescriptor: nil, returnID: AEReturnID(kAutoGenerateReturnID), transactionID: AETransactionID(kAnyTransactionID))
event.setParam(urlDescriptor, forKeyword: keyDirectObject)
NSAppleEventManager.shared().dispatchRawAppleEvent(event.aeDesc!, withRawReply: UnsafeMutablePointer(mutating: NSAppleEventDescriptor.null().aeDesc!), handlerRefCon: &url)
Now we can test that
I left out some small details which I didn’t find noteworthy (e.g. how the
This was my first time writing about a technical topic, I appreciate all feedback whether it’s about my style of writing or my code.