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.
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 state | Custom URL scheme | Universal 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.
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.
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:
@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.