My new project: Tact, a simple chat app.

The state of Universal Links as of August 2021

August 22, 2021

I did an investigation of Universal Links. They’re supposedly this great technology that allows HTTPS links to be opened automatically in your iOS or macOS app if all the conditions are right, and thus to provide deep links to your content straight in your app.

TL;DR: I could not get two key pieces of the technology to work: the developer mode, and onOpenURL when using macOS with SwiftUI. Although Apple keeps telling us that custom URL schemes are deprecated and we should switch to Universal Links, the latter is a dealbreaker with SwiftUI app lifecycle. You can use Universal Links, but be prepared for them not being reliable.

Let’s dig in.

Universal Links have been around for quite a while already. In WWDC19 and WWDC20, there were two sessions with the identical name “What’s new in Universal Links”. Go watch these to learn of important updates. Much of both sessions is dedicated to the nuances of handling links and directing which links are handled by your apps and which aren’t.

Before I dug into handling different links differently, I wanted to get the basics working. I ran into some trouble with that, and the rest of this post will investigate these troubles.

apple-app-site-association file, CDN, and developer mode

Universal Links require that you host a file on your web site at the location .well-known/apple-app-site-association (henceforth I will call it AASA). The simplest content of this file can be like this:

{
    "applinks": {
        "details": [
            {
                "appIDs": [ "ABCD1234.com.example.YourAppId" ]
            }
        ]
    }
}

This tells the system that this website authorizes the indicated app ID to handle its links as Universal Links. If you wish, you can have more info in this file to say which links should and shouldn’t be handled by the app. Watch the WWDC sessions for more info about that. But this simple file is sufficient for a demo that causes all of the links to be handled as Universal Links by the app.

WWDC20 and the platforms of 2020 brought us an important update: AASA is no longer downloaded by the clients directly from my website, but is instead cached by Apple’s CDN in the production situation. Since their CDN cannot access internal development servers, there is a new “developer” mode that you need to enable both on each client device where you develop, as well as specifying ?mode=developer in your Associated Domains applinks entitlement.

Just as a reference for myself, you enable the developer mode on macOS with this command: swcutil developer-mode -e true On iOS, there is a setting “Associated Domains Development”.

WWDC20 video claims that you can use “any valid certificate” to secure the HTTPS server that is serving the association file in developer mode, as opposed to “System-trusted root certificate“ required in production configuration.

I tried a bunch of certificates for developer mode: both self-signed, and a certificate issued by a custom CA added to the system trust root store, as I describe in this post. None of it worked.

The method of debugging this is to connect to your iOS device console with Xcode, and filter it for swcd. I saw a line Trust evaluate failure: [root AnchorTrusted] which was present in case of a certificate signed by custom CA trusted in the root store, and was not present when using a truly trusted certificate (e.g public server whose certificate is issued by LetsEncrypt).

root AnchorTrusted error

In case of a truly self-signed cert, you get different errors: Trust evaluate failure: [leaf AnchorTrusted SSLHostname ServerAuthEKU], and another message saying Failed to verify server trust <private> for task AASA-3619F364-FD45-41DE-AB59-4D4F120377AD { domain: 19….16….0.10?mode=developer, bytes: 0, route: .wk }: Error Domain=SWCErrorDomain Code=100 "Disallowed trust result type." UserInfo={Line=167, Function=-[SWCSecurityGuard verifyTrust:allowInstalledRootCertificates:error:], NSDebugDescription=Disallowed trust result type., TrustResultType=5}

So, one of the following two statements is true. Either the developer mode is simply not working correctly, or I have misunderstood what “any valid certificate” means in this context. There doesn’t seem to be any more info or documentation available about this.

A while ago, I looked quite deeply about HTTPS certificate validation, and how custom root CAs work in modern macOS and iOS: again, I refer you to this post on the topic. If I have a certificate that works for other Apple Transport Security purposes, but does not work for AASA development mode, I question what’s different and special about this mode and what kind of certificates it requires.

Long story short: AASA developer mode works only on public servers whose certificate is issued by trusted root CAs. Custom trusted roots don’t seem to work. Filed Feedback ID for Apple: FB9551780.

Okay. So this was not actually a showstopper for me, since I had access to a development server with a trusted certificate where I could play around with my AASA.

The other bug I ran into, tested on both Big Sur and Monterey, involves how the app receives Universal Links.

In SwiftUI, the way to handle incoming Universal Links is very straightforward. You just place a onOpenURL modifier on any view that you’d like to handle the link, perhaps like this:

ContentView()
    .onOpenURL { url in
        print("Got url in content view: \(url)")
    }

Instead of printing the URL, you’d of course want to parse it and do something with it. This is just for the demo.

If you figure out the certificate stuff discussed above, then on iOS everything works pretty much as it should, on both simulator and devices. The code above does what it should, and is reliably called when opening Universal Links.

On macOS, although the system correctly switches to my app upon opening an Universal Link, onOpenURL is never called for me, no matter what I try. This yields an especially unfortunate situation, where Universal Links seemingly work and switch to my app instead of opening the website, but give my app no indication of the link, and so I cannot provide the correct experience for my user since I have no access to the link they clicked on.

I did a test also with AppKit lifecycle, where Universal Links works as expected. For an incoming universal link, I consistently receive the much-longer-named user activity continuation method called in my NSApplicationDelegate class. So it works as expected with AppKit. I’d expect it to work equally well in SwiftUI lifecycle: there’s no sign in documentation that it wouldn’t or shouldn’t. For simpler apps, SwiftUI multiplatform app lifecycle is great. I wish Universal Links worked as well with the SwiftUI lifecycle as they do on iOS.

If anyone at Apple is reading this, I’ve filed FB9544271.

The WWDC videos about Universal Links from both 2019 and 2020 tell us in no uncertain temrs that custom URL schemes for apps are deprecated, and we should instead switch to Universal Links. I found the above two problems quite disheartening towards that end. Custom schemes have their warts, but not these kinds, they work very reliably on all platforms for my practical purposes. So whatever deep-linking solution I end up with, it will probably involve a combination of Universal Links and custom URL scheme with additional fallbacks for cases like the macOS one, where the Universal Link looks like it works, but really doesn’t, so I must provide additional escape hatches and workarounds if the link is really critical for my app.