Deep Links

Universal Links and the soar:// custom scheme, from capture to route.

The app routes external URLs to specific screens through a single-slot handler fed by three capture channels. This page covers the two entry types, how a URL becomes a DeepLinkDestination, and how the shell consumes it on cold vs warm start. It's Integrated — the custom scheme works out of the box; Universal Links need one entitlement + a hosted file.

Key files

Two entry channels

Prop

Type

The template ships the soar scheme registered and the entitlement present but pointed at a placeholder:

<!-- SoarStarterSwift.entitlements -->
<key>com.apple.developer.associated-domains</key>
<array>
  <string>applinks:example.com</string>
</array>

To enable Universal Links, replace example.com with your LINKING_DOMAIN, then host an apple-app-site-association file at https://<domain>/.well-known/apple-app-site-association declaring your app's appID (<TeamID>.com.soarstarter.SoarStarterSwift). Until then, only the soar:// scheme resolves.

Three capture sources → one handler

Every entry point funnels into DeepLinkHandler.set(_:):

.onOpenURL (RootView)                    ← soar:// while app is foregrounded/launched
application(_:continue:) (AppDelegate)    ← Universal Link tap (NSUserActivity)
notification tap (NotificationsController) ← push payload deeplink/url


DeepLinkHandler.pending : DeepLinkDestination?   (single slot, @Observable)


MainShellView consumes on .onAppear + .onChange(of: pending)

RootView.onOpenURL first gives Google Sign-In and Supabase's auth callback a chance to claim the URL (soar://auth-callback), then hands the rest to DeepLinkHandler.

Resolving a URL

DeepLinkResolver.resolve(_:) normalizes both URL shapes and maps to a DeepLinkDestination enum, returning nil for unknown paths (which are ignored):

  • For soar://todos the host carries the route; for https://domain/todos the path does — the resolver joins them so both shapes work.
  • A ?route= query overrides the path, so push payloads can carry the target as a query parameter (e.g. soar:///notifications?route=me).
  • Paths are normalized (leading slash, collapsed //, trimmed trailing slash).

Routable destinations

PathDestinationLands on
/, /home.homeHome tab
/component.componentComponents tab
/showcase.showcaseShowcase tab (removable demo)
/todos.todosHome tab → Todos
/me, /profile.meMe tab
/personal-info.personalInfoMe → Personal Info
/notifications.notificationsMe → Notifications
/permissions.permissionsMe → Permissions
/refer.referMe → Refer
/paywall.paywallMe → Paywall (prompts sign-in if needed)
/redeem-code.redeemCodeMe → Redeem code (prompts sign-in)

Cold vs warm start

MainShellView drains the handler in two places so both work:

  • Warm start.onChange(of: env.deepLinks.pending) fires the moment a URL arrives while the shell is already on screen.
  • Cold start — a URL captured during launch is still in pending when the shell first appears, so .onAppear consumes it.

routePendingDeepLink() calls consume() (which reads then clears the slot), closes the drawer, selects the target tab, and sets that tab's navigation path. Because each tab has its own @State path, routing to .todos sets homePath = [.todos] without disturbing other tabs' back-stacks. Paywall / redeem targets present the auth sheet first when signed out.

Referral share flow

ReferralLinkProvider.inviteURL() builds the share URL from LINKING_DOMAIN (https://<domain>/), falling back to soar:/// when no domain is set. The Refer screen wraps it in a ShareLink and fires a referral invite shared analytics event — a natural home for a ?route=refer deep link back into the app.

Testing

Custom-scheme links work in the simulator — no server, no device:

xcrun simctl openurl booted "soar://todos"
xcrun simctl openurl booted "soar:///notifications?route=me"

The bundled DeepLinksShowcaseView (in the removable Showcase) lists the routable targets as tappable buttons for a quick manual sweep. Universal Links can only be verified on a device once the AASA file is live.

Verify

  • xcrun simctl openurl booted "soar://todos" opens the Home tab on Todos.
  • soar:///paywall while signed out presents the auth sheet, then the paywall.
  • An unknown path (soar://nope) is ignored (no crash, no navigation).

On this page