Nikola Čorlija

Deep links come through two doors

A link that works while the app is running can do nothing on a cold start. Why, and how to route both paths through one place.

iOS · UIKit · SwiftUI · Deep links

The bug report usually reads: "Tapping the link in the email opens the app, but not the right screen." You try it, and it works. The difference is that your app was already running. Theirs was not.

Two doors in UIKit

With scenes, a URL or universal link reaches your app through one of two callbacks, depending on whether the scene already exists:

App stateCustom URL schemeUniversal link
Running (warm)scene(_:openURLContexts:)scene(_:continue:)
Not running (cold)connectionOptions.urlContexts in scene(_:willConnectTo:options:)connectionOptions.userActivities in the same method

On a cold start the warm-start callbacks are not called. The link is handed to you once, inside the connection options, and if willConnectTo ignores them, it is gone.

SceneDelegate.swift
func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    setUpWindow(for: scene)

    if let url = connectionOptions.urlContexts.first?.url {
        router.handle(url)
    } else if let activity = connectionOptions.userActivities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb }),
              let url = activity.webpageURL {
        router.handle(url)
    }
}

func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
    if let url = contexts.first?.url { router.handle(url) }
}

func scene(_ scene: UIScene, continue activity: NSUserActivity) {
    if let url = activity.webpageURL { router.handle(url) }
}

All three paths end in the same router.handle(_:). That is the whole fix: one entry point, three doors leading to it.

SwiftUI closes one door for you

In a SwiftUI app lifecycle, .onOpenURL is called for both cold and warm starts, for custom schemes and universal links alike. Universal links can also be caught with .onContinueUserActivity(NSUserActivityTypeBrowsingWeb), but .onOpenURL is usually simpler.

swift
WindowGroup {
    RootView()
        .onOpenURL { url in router.handle(url) }
}

The second half of the bug: too early

Even when the link is caught on a cold start, the screen it points to may not be ready: the user is not logged in yet, remote config has not loaded, or the root navigation stack does not exist. Routing immediately pushes onto nothing.

A small fix is to let the router hold one pending destination and release it when the app reaches a state that can show it:

Router.swift
@MainActor
final class Router {
    private var pending: Destination?
    private var isReady = false

    func handle(_ url: URL) {
        guard let destination = Destination(url: url) else { return }
        if isReady { show(destination) } else { pending = destination }
    }

    func appDidBecomeReady() {
        isReady = true
        if let destination = pending {
            pending = nil
            show(destination)
        }
    }

    private func show(_ destination: Destination) { /* push or present */ }
}

Testing both doors

  • Warm: run the app, then open the link from Notes or with xcrun simctl openurl booted <url>.
  • Cold: kill the app first, then open the link the same way.
  • Universal links: test on a device or simulator with the associated domain configured; links typed into Safari's address bar on the same domain open in Safari, not the app.

← All notes