diff --git a/RoadFlare/RoadFlare/RoadFlare.entitlements b/RoadFlare/RoadFlare/RoadFlare.entitlements
index 0e2e448..e40a278 100644
--- a/RoadFlare/RoadFlare/RoadFlare.entitlements
+++ b/RoadFlare/RoadFlare/RoadFlare.entitlements
@@ -4,6 +4,7 @@
com.apple.developer.associated-domains
+ applinks:roadflare.app
webcredentials:roadflare.app
diff --git a/RoadFlare/RoadFlare/RoadFlareApp.swift b/RoadFlare/RoadFlare/RoadFlareApp.swift
index 9dd0c8f..884e01c 100644
--- a/RoadFlare/RoadFlare/RoadFlareApp.swift
+++ b/RoadFlare/RoadFlare/RoadFlareApp.swift
@@ -35,6 +35,14 @@ struct RoadFlareApp: App {
// the parsed driver intent into the drivers tab.
appState.handleIncomingURL(url)
}
+ .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
+ // Universal Link dispatch (e.g. `https://roadflare.app/share/d/npub1...`).
+ // Symmetric with `.onOpenURL` above; AppState extracts the
+ // `webpageURL` and routes through the same `handleIncomingURL`
+ // path. Requires `applinks:roadflare.app` in entitlements
+ // plus the AASA file hosted on roadflare.app — see issue #63.
+ appState.handleIncomingUserActivity(activity)
+ }
}
}
}
diff --git a/RoadFlare/RoadFlareCore/ViewModels/AppState.swift b/RoadFlare/RoadFlareCore/ViewModels/AppState.swift
index 1a35b9f..8a43e60 100644
--- a/RoadFlare/RoadFlareCore/ViewModels/AppState.swift
+++ b/RoadFlare/RoadFlareCore/ViewModels/AppState.swift
@@ -741,6 +741,20 @@ public final class AppState {
selectedTab = 1 // Drivers tab — see MainTabView.swift
}
+ /// Route an incoming Universal Link user activity into the app.
+ ///
+ /// Universal Links from `https://roadflare.app/share/...` arrive as an
+ /// `NSUserActivity` of type `NSUserActivityTypeBrowsingWeb` with the URL
+ /// in `webpageURL`. Extract the URL and dispatch through the same path
+ /// as custom-scheme URLs — `DriverQRCodeParser` already accepts the
+ /// `https://roadflare.app/share/d/` and `/share/r/` shapes.
+ /// Requires `applinks:roadflare.app` in entitlements and the AASA file
+ /// at `https://roadflare.app/.well-known/apple-app-site-association`.
+ public func handleIncomingUserActivity(_ activity: NSUserActivity) {
+ guard let url = activity.webpageURL else { return }
+ handleIncomingURL(url)
+ }
+
// MARK: - Ping Driver Hint
/// Entry point for the "Ping a Driver" CTA in the ride flow. Switches to
diff --git a/RoadFlare/RoadFlareTests/AppState/HandleIncomingURLTests.swift b/RoadFlare/RoadFlareTests/AppState/HandleIncomingURLTests.swift
index 1aa957c..7e0e2cc 100644
--- a/RoadFlare/RoadFlareTests/AppState/HandleIncomingURLTests.swift
+++ b/RoadFlare/RoadFlareTests/AppState/HandleIncomingURLTests.swift
@@ -85,6 +85,74 @@ struct HandleIncomingURLTests {
#expect(appState.pendingDriverDeepLink == ParsedDriverQRCode(pubkeyInput: npub, scannedName: "Driver Dan"))
}
+ @MainActor
+ @Test func universalLinkDriverShareRoutesToDriversTab() throws {
+ // Universal Links from `https://roadflare.app/share/d/` arrive
+ // via `.onContinueUserActivity(NSUserActivityTypeBrowsingWeb)`.
+ // `handleIncomingUserActivity` extracts `webpageURL` and dispatches
+ // through the same `handleIncomingURL` path used by the custom scheme.
+ // Issue #63.
+ let appState = AppState()
+ appState.selectedTab = 0
+ let npub = try makeNpub(hex: String(repeating: "6f", count: 32))
+ let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb)
+ activity.webpageURL = URL(string: "https://roadflare.app/share/d/\(npub)?name=Universal%20Linker")
+
+ appState.handleIncomingUserActivity(activity)
+
+ #expect(appState.pendingDriverDeepLink == ParsedDriverQRCode(pubkeyInput: npub, scannedName: "Universal Linker"))
+ #expect(appState.selectedTab == 1)
+ }
+
+ @MainActor
+ @Test func universalLinkRiderShareRoutesToDriversTab() throws {
+ // `/share/r/` URLs go through the same parser path — the
+ // parser extracts the embedded npub regardless of `d` vs `r` segment.
+ // Routing them to the drivers tab is consistent with the parser's
+ // existing behavior for the matching custom-scheme inputs.
+ let appState = AppState()
+ appState.selectedTab = 0
+ let npub = try makeNpub(hex: String(repeating: "7a", count: 32))
+ let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb)
+ activity.webpageURL = URL(string: "https://roadflare.app/share/r/\(npub)")
+
+ appState.handleIncomingUserActivity(activity)
+
+ #expect(appState.pendingDriverDeepLink == ParsedDriverQRCode(pubkeyInput: npub, scannedName: nil))
+ #expect(appState.selectedTab == 1)
+ }
+
+ @MainActor
+ @Test func userActivityWithoutWebpageURLIsDropped() {
+ // If the activity arrives without a `webpageURL` (shouldn't happen
+ // for `NSUserActivityTypeBrowsingWeb` in practice, but defend anyway),
+ // nothing happens — tab stays put, no deep-link intent populated.
+ let appState = AppState()
+ appState.selectedTab = 0
+ let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb)
+
+ appState.handleIncomingUserActivity(activity)
+
+ #expect(appState.pendingDriverDeepLink == nil)
+ #expect(appState.selectedTab == 0)
+ }
+
+ @MainActor
+ @Test func universalLinkUnknownPathIsDropped() {
+ // Unknown roadflare.app paths (no embedded npub) drop silently —
+ // the AASA `components` filter should keep them out of the app in
+ // production, but the in-app parser is the second line of defense.
+ let appState = AppState()
+ appState.selectedTab = 0
+ let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb)
+ activity.webpageURL = URL(string: "https://roadflare.app/about")
+
+ appState.handleIncomingUserActivity(activity)
+
+ #expect(appState.pendingDriverDeepLink == nil)
+ #expect(appState.selectedTab == 0)
+ }
+
@MainActor
@Test func navigationIntentSurvivesIdentityReplacementWhenNoKeypair() async throws {
// Cold-start regression: when a `roadflared:` URL arrives before the
@@ -119,4 +187,29 @@ struct HandleIncomingURLTests {
#expect(appState.pendingDriverDeepLink != nil, "Deep link must survive identity replacement when no prior keypair existed")
#expect(appState.selectedTab == 1, "Tab selection must survive identity replacement when no prior keypair existed")
}
+
+ @MainActor
+ @Test func userActivityNavigationIntentSurvivesIdentityReplacementWhenNoKeypair() async throws {
+ // Cold-start regression parity for Universal Links: same invariant as
+ // `navigationIntentSurvivesIdentityReplacementWhenNoKeypair` above, but
+ // exercised through `handleIncomingUserActivity`. Universal Links are
+ // the long-term primary share path (per ADR-0012 + issue #63), so the
+ // pre-onboarding tap path needs explicit coverage on this entry point
+ // — not just the `roadflared:` shim. See PR #66 for the precedent of
+ // pinning parallel regression coverage on each new entry seam.
+ let appState = AppState()
+ let npub = try makeNpub(hex: String(repeating: "8e", count: 32))
+ let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb)
+ activity.webpageURL = URL(string: "https://roadflare.app/share/d/\(npub)")
+
+ appState.handleIncomingUserActivity(activity)
+ #expect(appState.pendingDriverDeepLink != nil)
+ #expect(appState.selectedTab == 1)
+ #expect(appState.keypair == nil)
+
+ await appState.logout()
+
+ #expect(appState.pendingDriverDeepLink != nil, "Universal Link intent must survive identity replacement when no prior keypair existed")
+ #expect(appState.selectedTab == 1, "Tab selection must survive identity replacement when no prior keypair existed")
+ }
}