Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions Examples/UniversalLinkExample.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import SwiftUI
import AppRouterPlus

// MARK: - Types

enum ULTab: String, TabType, CaseIterable {
case home, profile
}

enum ULDestination: DeepLinkableDestination {
case home
case profile(userId: String)

static func path(for d: ULDestination) -> String {
switch d {
case .home: return "home"
case .profile: return "profile"
}
}

static func from(path: String, fullPath: [String], parameters: [String: [String]]) -> ULDestination? {
switch path {
case "home": return .home
case "profile":
guard let uid = parameters["userId"]?.last else { return nil }
return .profile(userId: uid)
default: return nil
}
}
}

enum ULSheet: String, SheetType {
case settings
var id: String { rawValue }
}

// MARK: - Config (declare once)

extension UniversalLinkConfig {
static let example = UniversalLinkConfig(
allowedHosts: ["example.com"]
)
}

// MARK: - Demo View

struct UniversalLinkDemoView: View {
@State var router = Router<ULTab, ULDestination, ULSheet>(initialTab: .home)

var body: some View {
TabView(selection: $router.selectedTab) {
NavigationStack(path: router.binding(for: .home)) {
Text("Home root")
.navigationDestination(for: ULDestination.self) { dest in
switch dest {
case .home: Text("Home")
case .profile(let uid): Text("Profile \(uid)")
}
}
}
.tabItem { Label("Home", systemImage: "house") }
.tag(ULTab.home)

NavigationStack(path: router.binding(for: .profile)) {
Text("Profile root")
.navigationDestination(for: ULDestination.self) { dest in
switch dest {
case .home: Text("Home")
case .profile(let uid): Text("Profile \(uid)")
}
}
}
.tabItem { Label("Profile", systemImage: "person") }
.tag(ULTab.profile)
}
// Custom-scheme deeplink: myapp://home?tab=profile
.onOpenURL { url in
_ = router.navigate(to: url)
}
// Universal Link: https://example.com/profile?userId=42&tab=profile
.onContinueUserActivity(NSUserActivityTypeBrowseWeb) { activity in
if let url = activity.webpageURL {
_ = router.navigate(toUniversalLink: url, config: .example)
}
}
}
}

// MARK: - Build a shareable UL

func makeShareLink(userId: String) -> URL? {
URLNavigationHelper.buildUniversalLink(
host: "example.com",
tab: ULTab.profile,
destinations: [ULDestination.profile(userId: userId)],
extraQuery: ["userId": userId]
)
}
6 changes: 5 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ let package = Package(
.library(name: "AppRouterPlus", targets: ["AppRouterPlus"])
],
targets: [
.target(name: "AppRouterPlus")
.target(name: "AppRouterPlus"),
.testTarget(
name: "AppRouterPlusTests",
dependencies: ["AppRouterPlus"]
)
]
)
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,70 @@ TabView(selection: $router.selectedTab) {
.onOpenURL { url in _ = router.navigate(to: url) }
```

## Universal Links

Native handling of Apple Universal Links (`https://yourdomain.com/...`) — parameterized for any host, scheme, and optional path prefix. No hard-coded domains in the library.

### Setup (app side)

1. Enable `Associated Domains` capability in Xcode (e.g. `applinks:yourdomain.com`).
2. Serve `.well-known/apple-app-site-association` on your domain (see Apple docs).
3. Declare a `UniversalLinkConfig` once:

```swift
extension UniversalLinkConfig {
static let myApp = UniversalLinkConfig(
allowedHosts: ["yourdomain.com"],
pathPrefix: nil // e.g. "/app" if your URLs use a prefix
)
}
```

4. Hook `.onContinueUserActivity(NSUserActivityTypeBrowseWeb)` at root and extract `webpageURL`:

```swift
ContentView()
.onOpenURL { url in
// Custom-scheme deeplink (myapp://...)
_ = router.navigate(to: url)
}
.onContinueUserActivity(NSUserActivityTypeBrowseWeb) { activity in
// Universal link (https://...)
if let url = activity.webpageURL {
_ = router.navigate(toUniversalLink: url, config: .myApp)
}
}
```

> The library intentionally does NOT wrap `NSUserActivity` — it stays URL-based so you can feed URLs from any source (Universal Links, push notification payloads, share extensions, etc.) without coupling to a specific delivery API.

### Parsing

`URLNavigationHelper.parseUniversalLink(url, config:, tabType:, destinationType:)` returns `(tab, destinations)` or `nil` on validation failure. Validates scheme (default `https`), host (exact match, case-insensitive), optional path prefix (per-segment match).

### Building UL URLs

```swift
let url = URLNavigationHelper.buildUniversalLink(
host: "yourdomain.com",
tab: AppTab.profile,
destinations: [Destination.profile(userId: "42")],
extraQuery: ["userId": "42"]
)
// → https://yourdomain.com/profile?userId=42&tab=profile
```

### Edge cases

- Host comparison is case-insensitive (RFC 3986); `allowedHosts` are lowercased at config init.
- `pathPrefix` matches per-segment: `"/app"` accepts `"/app"` and `"/app/x"`, rejects `"/appstore"`.
- Multi-value `?tab=a&tab=b` → LAST wins.
- `deepPush: true` + `policy: .replace` runs navigation asynchronously (background `MainActor` Task); the method returns `true` immediately. Pass `deepPush: false` for synchronous completion.

### SimpleRouter

`SimpleRouter` (single-tab variant) also supports `navigate(to: URL)` and `navigate(toUniversalLink:)` with the same semantics, minus tab handling.

## Navigation policies

```swift
Expand Down
54 changes: 54 additions & 0 deletions Sources/AppRouterPlus/Router+UniversalLinks.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

public extension Router where Destination: DeepLinkableDestination {

/// Navigate from a Universal Link URL (inline params).
/// - Returns: `false` if scheme/host/prefix validation fails or any path segment unmappable.
/// - Note: When `deepPush == true` and `policy == .replace`, navigation completes
/// asynchronously on the next MainActor ticks; this method returns immediately.
@discardableResult
func navigate(
toUniversalLink url: URL,
allowedHosts: Set<String>,
pathPrefix: String? = nil,
allowedSchemes: Set<String> = ["https"],
policy: NavigationPolicy = .replace,
deepPush: Bool = true
) -> Bool {
guard let parsed = URLNavigationHelper.parseUniversalLink(
url,
allowedHosts: allowedHosts,
pathPrefix: pathPrefix,
allowedSchemes: allowedSchemes,
tabType: Tab.self,
destinationType: Destination.self
) else {
return false
}
if let t = parsed.tab { selectedTab = t }
if deepPush, policy == .replace {
return deepPushTo(parsed.destinations)
} else {
navigateTo(parsed.destinations, policy: policy, for: selectedTab)
return true
}
}

/// Config-flavored overload.
@discardableResult
func navigate(
toUniversalLink url: URL,
config: UniversalLinkConfig,
policy: NavigationPolicy = .replace,
deepPush: Bool = true
) -> Bool {
navigate(
toUniversalLink: url,
allowedHosts: config.allowedHosts,
pathPrefix: config.pathPrefix,
allowedSchemes: config.allowedSchemes,
policy: policy,
deepPush: deepPush
)
}
}
3 changes: 2 additions & 1 deletion Sources/AppRouterPlus/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,8 @@ public final class Router<Tab, Destination, Sheet> where Tab: TabType, Destinati
}

/// Perform multi-step push for better SwiftUI stability.
private func deepPushTo(_ destinations: [Destination]) -> Bool where Destination: DeepLinkableDestination {
/// Internal so URL-handling extensions (custom-scheme + Universal Links) can reuse it.
internal func deepPushTo(_ destinations: [Destination]) -> Bool where Destination: DeepLinkableDestination {
let t = selectedTab
paths[t] = []
if destinations.isEmpty { return true }
Expand Down
61 changes: 61 additions & 0 deletions Sources/AppRouterPlus/SimpleRouter+URLs.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

public extension SimpleRouter where Destination: DeepLinkableDestination {

/// Navigate from a custom-scheme deeplink (e.g. `myapp://...`).
/// Mirrors `Router.navigate(to: URL)` but without tab handling.
@discardableResult
func navigate(to url: URL, policy: NavigationPolicy = .replace) -> Bool {
guard let parsed = URLNavigationHelper.parse(
url, tabType: NoTab.self, destinationType: Destination.self
) else {
return false
}
navigateTo(parsed.destinations, policy: policy)
return true
}

/// Navigate from a Universal Link URL (inline params).
@discardableResult
func navigate(
toUniversalLink url: URL,
allowedHosts: Set<String>,
pathPrefix: String? = nil,
allowedSchemes: Set<String> = ["https"],
policy: NavigationPolicy = .replace
) -> Bool {
guard let parsed = URLNavigationHelper.parseUniversalLink(
url,
allowedHosts: allowedHosts,
pathPrefix: pathPrefix,
allowedSchemes: allowedSchemes,
tabType: NoTab.self,
destinationType: Destination.self
) else {
return false
}
navigateTo(parsed.destinations, policy: policy)
return true
}

/// Config-flavored overload.
@discardableResult
func navigate(
toUniversalLink url: URL,
config: UniversalLinkConfig,
policy: NavigationPolicy = .replace
) -> Bool {
navigate(
toUniversalLink: url,
allowedHosts: config.allowedHosts,
pathPrefix: config.pathPrefix,
allowedSchemes: config.allowedSchemes,
policy: policy
)
}
}

/// Private placeholder TabType for SimpleRouter URL parsing (single case, ignored).
internal enum NoTab: String, TabType {
case _none
}
Loading