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://todosthe host carries the route; forhttps://domain/todosthe 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
| Path | Destination | Lands on |
|---|---|---|
/, /home | .home | Home tab |
/component | .component | Components tab |
/showcase | .showcase | Showcase tab (removable demo) |
/todos | .todos | Home tab → Todos |
/me, /profile | .me | Me tab |
/personal-info | .personalInfo | Me → Personal Info |
/notifications | .notifications | Me → Notifications |
/permissions | .permissions | Me → Permissions |
/refer | .refer | Me → Refer |
/paywall | .paywall | Me → Paywall (prompts sign-in if needed) |
/redeem-code | .redeemCode | Me → 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
pendingwhen the shell first appears, so.onAppearconsumes 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:///paywallwhile signed out presents the auth sheet, then the paywall. - An unknown path (
soar://nope) is ignored (no crash, no navigation).
