diff --git a/Examples/UniversalLinkExample.swift b/Examples/UniversalLinkExample.swift new file mode 100644 index 0000000..a8de2bc --- /dev/null +++ b/Examples/UniversalLinkExample.swift @@ -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(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] + ) +} diff --git a/Package.swift b/Package.swift index 37607d8..1fb4b5c 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,10 @@ let package = Package( .library(name: "AppRouterPlus", targets: ["AppRouterPlus"]) ], targets: [ - .target(name: "AppRouterPlus") + .target(name: "AppRouterPlus"), + .testTarget( + name: "AppRouterPlusTests", + dependencies: ["AppRouterPlus"] + ) ] ) diff --git a/README.md b/README.md index b2504cf..b9caf9e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/AppRouterPlus/Router+UniversalLinks.swift b/Sources/AppRouterPlus/Router+UniversalLinks.swift new file mode 100644 index 0000000..173ca4c --- /dev/null +++ b/Sources/AppRouterPlus/Router+UniversalLinks.swift @@ -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, + pathPrefix: String? = nil, + allowedSchemes: Set = ["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 + ) + } +} diff --git a/Sources/AppRouterPlus/Router.swift b/Sources/AppRouterPlus/Router.swift index 37f875d..6ec0aa9 100644 --- a/Sources/AppRouterPlus/Router.swift +++ b/Sources/AppRouterPlus/Router.swift @@ -356,7 +356,8 @@ public final class Router 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 } diff --git a/Sources/AppRouterPlus/SimpleRouter+URLs.swift b/Sources/AppRouterPlus/SimpleRouter+URLs.swift new file mode 100644 index 0000000..6d65254 --- /dev/null +++ b/Sources/AppRouterPlus/SimpleRouter+URLs.swift @@ -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, + pathPrefix: String? = nil, + allowedSchemes: Set = ["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 +} diff --git a/Sources/AppRouterPlus/URLNavigationHelper+UniversalLinks.swift b/Sources/AppRouterPlus/URLNavigationHelper+UniversalLinks.swift new file mode 100644 index 0000000..5de1ec1 --- /dev/null +++ b/Sources/AppRouterPlus/URLNavigationHelper+UniversalLinks.swift @@ -0,0 +1,190 @@ +import Foundation + +public extension URLNavigationHelper { + + /// Parse a Universal Link URL into `(tab, destinations)`. + /// - Returns: `nil` if scheme/host/prefix validation fails or any path segment is unmappable. + static func parseUniversalLink( + _ url: URL, + allowedHosts: Set, + pathPrefix: String? = nil, + allowedSchemes: Set = ["https"], + tabType: Tab.Type, + destinationType: Destination.Type + ) -> (tab: Tab?, destinations: [Destination])? + where Tab: TabType, Destination: DeepLinkableDestination + { + // Normalize (idempotent for already-normalized inputs from UniversalLinkConfig) + let normHosts = Set(allowedHosts.map { $0.lowercased() }) + let normSchemes = Set(allowedSchemes.map { $0.lowercased() }) + let normPrefix: String? = pathPrefix.map { p in + var s = p + while s.count > 1 && s.hasSuffix("/") { s.removeLast() } + return s + } + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme?.lowercased(), + normSchemes.contains(scheme), + let host = components.host?.lowercased(), + normHosts.contains(host) + else { return nil } + + // Validate / strip path prefix per-segment + var remaining = components.path + if let prefix = normPrefix { + if remaining == prefix || remaining == prefix + "/" { + remaining = "" + } else if remaining.hasPrefix(prefix + "/") { + remaining = String(remaining.dropFirst(prefix.count)) + } else { + return nil + } + } + + // Build query multi-map + var params: [String: [String]] = [:] + if let items = components.queryItems { + for item in items { + params[item.name, default: []].append(item.value ?? "") + } + } + + // Resolve tab from query (LAST wins, matches existing parse) + var targetTab: Tab? = nil + if let rawTab = params["tab"]?.last { + for t in Tab.allCases { + if String(describing: t) == rawTab { targetTab = t; break } + if let r = t as? any CustomStringConvertible, r.description == rawTab { targetTab = t; break } + } + } + + // Split remaining path into segments + let segments = remaining + .split(separator: "/", omittingEmptySubsequences: true) + .map(String.init) + + guard !segments.isEmpty else { + return (targetTab, []) + } + + var destinations: [Destination] = [] + for (i, seg) in segments.enumerated() { + let subpath = Array(segments.prefix(i + 1)) + if let dest = Destination.from(path: seg, fullPath: subpath, parameters: params) { + destinations.append(dest) + } else { + return nil + } + } + return (targetTab, destinations) + } + + /// Config-flavored overload of `parseUniversalLink`. + static func parseUniversalLink( + _ url: URL, + config: UniversalLinkConfig, + tabType: Tab.Type, + destinationType: Destination.Type + ) -> (tab: Tab?, destinations: [Destination])? + where Tab: TabType, Destination: DeepLinkableDestination + { + parseUniversalLink( + url, + allowedHosts: config.allowedHosts, + pathPrefix: config.pathPrefix, + allowedSchemes: config.allowedSchemes, + tabType: tabType, + destinationType: destinationType + ) + } + + /// Build a Universal Link URL with a tab query parameter. + /// Symmetric with `build(scheme:tab:destinations:extraQuery:)` but for UL. + /// - Note: Does NOT validate host against any allow-list (parse-time concern). + /// - Note: `extraQuery` is single-value per key; for multi-value URLs build manually. + static func buildUniversalLink( + host: String, + scheme: String = "https", + pathPrefix: String? = nil, + tab: Tab?, + destinations: [Destination], + extraQuery: [String: String] = [:] + ) -> URL? + where Tab: TabType, Destination: DeepLinkableDestination + { + _buildUniversalLink( + host: host, + scheme: scheme, + pathPrefix: pathPrefix, + tabRaw: tab.map { String(describing: $0) }, + destinations: destinations, + extraQuery: extraQuery + ) + } + + /// Build a Universal Link URL without a tab parameter. + /// Convenience for callers that don't use tabs (e.g. SimpleRouter) or don't need to encode one. + static func buildUniversalLink( + host: String, + scheme: String = "https", + pathPrefix: String? = nil, + destinations: [Destination], + extraQuery: [String: String] = [:] + ) -> URL? + where Destination: DeepLinkableDestination + { + _buildUniversalLink( + host: host, + scheme: scheme, + pathPrefix: pathPrefix, + tabRaw: nil, + destinations: destinations, + extraQuery: extraQuery + ) + } + + private static func _buildUniversalLink( + host: String, + scheme: String, + pathPrefix: String?, + tabRaw: String?, + destinations: [Destination], + extraQuery: [String: String] + ) -> URL? + where Destination: DeepLinkableDestination + { + let normPrefix: String? = pathPrefix.map { p in + var s = p + while s.count > 1 && s.hasSuffix("/") { s.removeLast() } + return s + } + + var comps = URLComponents() + comps.scheme = scheme + comps.host = host + + var parts: [String] = [] + if let prefix = normPrefix, !prefix.isEmpty { + let trimmed = prefix.hasPrefix("/") ? String(prefix.dropFirst()) : prefix + if !trimmed.isEmpty { parts.append(trimmed) } + } + for dest in destinations { + parts.append(Destination.path(for: dest)) + } + comps.path = parts.isEmpty ? "/" : "/" + parts.joined(separator: "/") + + var items: [URLQueryItem] = [] + if let tabRaw { + items.append(URLQueryItem(name: "tab", value: tabRaw)) + } + for (k, v) in extraQuery { + items.append(URLQueryItem(name: k, value: v)) + } + if !items.isEmpty { + comps.queryItems = items + } + + return comps.url + } +} diff --git a/Sources/AppRouterPlus/UniversalLinkConfig.swift b/Sources/AppRouterPlus/UniversalLinkConfig.swift new file mode 100644 index 0000000..c5d1e29 --- /dev/null +++ b/Sources/AppRouterPlus/UniversalLinkConfig.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Validation parameters for Universal Link parsing. +/// Bundle once and pass via the `config:` overloads of `parseUniversalLink`, +/// `Router.navigate(toUniversalLink:)`, and `Router.handleUserActivity(_:)`. +public struct UniversalLinkConfig: Sendable, Hashable { + /// Allowed hosts (lowercased at init). + public let allowedHosts: Set + /// Optional path prefix to strip before destination parsing + /// (trailing slashes normalized at init). + public let pathPrefix: String? + /// Allowed URL schemes (lowercased at init). Default ["https"]. + public let allowedSchemes: Set + + public init( + allowedHosts: Set, + pathPrefix: String? = nil, + allowedSchemes: Set = ["https"] + ) { + self.allowedHosts = Set(allowedHosts.map { $0.lowercased() }) + self.allowedSchemes = Set(allowedSchemes.map { $0.lowercased() }) + if var p = pathPrefix { + while p.count > 1 && p.hasSuffix("/") { p.removeLast() } + self.pathPrefix = p + } else { + self.pathPrefix = nil + } + } +} diff --git a/Tests/AppRouterPlusTests/BuildUniversalLinkTests.swift b/Tests/AppRouterPlusTests/BuildUniversalLinkTests.swift new file mode 100644 index 0000000..239c7db --- /dev/null +++ b/Tests/AppRouterPlusTests/BuildUniversalLinkTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +@testable import AppRouterPlus + +@Suite("buildUniversalLink") +struct BuildUniversalLinkTests { + + @Test("Happy path: host + destination") + func happyPath() throws { + let url = try #require(URLNavigationHelper.buildUniversalLink( + host: "example.com", + destinations: [TestDestination.home] + )) + #expect(url.absoluteString == "https://example.com/home") + } + + @Test("With tab") + func withTab() throws { + let url = try #require(URLNavigationHelper.buildUniversalLink( + host: "example.com", + tab: TestTab.profile, + destinations: [TestDestination.home] + )) + #expect(url.absoluteString == "https://example.com/home?tab=profile") + } + + @Test("With extraQuery") + func withExtraQuery() throws { + let url = try #require(URLNavigationHelper.buildUniversalLink( + host: "example.com", + destinations: [TestDestination.detail(id: "123")], + extraQuery: ["id": "123"] + )) + #expect(url.absoluteString == "https://example.com/detail?id=123") + } + + @Test("With pathPrefix") + func withPathPrefix() throws { + let url = try #require(URLNavigationHelper.buildUniversalLink( + host: "example.com", + pathPrefix: "/app", + destinations: [TestDestination.home] + )) + #expect(url.absoluteString == "https://example.com/app/home") + } + + @Test("Trailing slash in pathPrefix normalized") + func trailingSlashNormalized() throws { + let url = try #require(URLNavigationHelper.buildUniversalLink( + host: "example.com", + pathPrefix: "/app/", + destinations: [TestDestination.home] + )) + #expect(url.absoluteString == "https://example.com/app/home") + } + + @Test("Custom scheme") + func customScheme() throws { + let url = try #require(URLNavigationHelper.buildUniversalLink( + host: "example.com", + scheme: "http", + destinations: [TestDestination.home] + )) + #expect(url.absoluteString == "http://example.com/home") + } + + @Test("Empty destinations + tab → root path") + func emptyDestinations() throws { + let url = try #require(URLNavigationHelper.buildUniversalLink( + host: "example.com", + tab: TestTab.home, + destinations: [TestDestination]() + )) + #expect(url.absoluteString == "https://example.com/?tab=home") + } + + @Test("Multiple destinations join with /") + func multipleDestinations() throws { + let url = try #require(URLNavigationHelper.buildUniversalLink( + host: "example.com", + destinations: [TestDestination.home, TestDestination.detail(id: "123")], + extraQuery: ["id": "123"] + )) + #expect(url.absoluteString == "https://example.com/home/detail?id=123") + } + + @Test("Round-trip: build → parse → original tab+destinations") + func roundTrip() throws { + let original = TestDestination.profile(userId: "u42") + let url = try #require(URLNavigationHelper.buildUniversalLink( + host: "example.com", + tab: TestTab.profile, + destinations: [original], + extraQuery: ["userId": "u42"] + )) + let parsed = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(parsed.tab == .profile) + #expect(parsed.destinations == [original]) + } +} diff --git a/Tests/AppRouterPlusTests/ParseUniversalLinkTests.swift b/Tests/AppRouterPlusTests/ParseUniversalLinkTests.swift new file mode 100644 index 0000000..b8fa044 --- /dev/null +++ b/Tests/AppRouterPlusTests/ParseUniversalLinkTests.swift @@ -0,0 +1,234 @@ +import Foundation +import Testing +@testable import AppRouterPlus + +@Suite("parseUniversalLink") +struct ParseUniversalLinkTests { + + // MARK: - Happy paths + + @Test("Happy path: scheme + host + path → destinations") + func happyPath() throws { + let url = URL(string: "https://example.com/detail?id=123")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.tab == nil) + #expect(result.destinations == [.detail(id: "123")]) + } + + @Test("Happy path with tab query") + func happyPathWithTab() throws { + let url = URL(string: "https://example.com/profile?userId=u1&tab=profile")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.tab == .profile) + #expect(result.destinations == [.profile(userId: "u1")]) + } + + @Test("Bare URL (no path) → (tab, [])") + func barePath() throws { + let url = URL(string: "https://example.com/?tab=home")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.tab == .home) + #expect(result.destinations.isEmpty) + } + + // MARK: - Scheme validation + + @Test("Reject scheme not in allowedSchemes") + func rejectBadScheme() { + let url = URL(string: "http://example.com/home")! + let result = URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, // https only + tabType: TestTab.self, destinationType: TestDestination.self + ) + #expect(result == nil) + } + + @Test("Accept additional scheme when configured") + func acceptHttpWhenAllowed() throws { + let url = URL(string: "http://example.com/home")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.httpAllowed, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.destinations == [.home]) + } + + @Test("Scheme comparison is case-insensitive") + func schemeCaseInsensitive() throws { + let url = URL(string: "HTTPS://example.com/home")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.destinations == [.home]) + } + + // MARK: - Host validation + + @Test("Reject host not in allowedHosts") + func rejectBadHost() { + let url = URL(string: "https://malicious.com/home")! + let result = URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + ) + #expect(result == nil) + } + + @Test("Host comparison is case-insensitive") + func hostCaseInsensitive() throws { + let url = URL(string: "https://EXAMPLE.com/home")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.destinations == [.home]) + } + + @Test("Multiple hosts: any one matches") + func multipleHosts() throws { + let url1 = URL(string: "https://example.com/home")! + let url2 = URL(string: "https://example.org/home")! + let r1 = URLNavigationHelper.parseUniversalLink( + url1, config: TestConfigs.multiHost, + tabType: TestTab.self, destinationType: TestDestination.self + ) + let r2 = URLNavigationHelper.parseUniversalLink( + url2, config: TestConfigs.multiHost, + tabType: TestTab.self, destinationType: TestDestination.self + ) + #expect(r1 != nil) + #expect(r2 != nil) + } + + // MARK: - pathPrefix validation (per-segment) + + @Test("pathPrefix bare match: path == prefix") + func prefixBareMatch() throws { + let url = URL(string: "https://example.com/app?tab=home")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.withPrefix, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.destinations.isEmpty) + } + + @Test("pathPrefix per-segment match") + func prefixPerSegmentMatch() throws { + let url = URL(string: "https://example.com/app/home")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.withPrefix, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.destinations == [.home]) + } + + @Test("pathPrefix rejects lexical false-positive /appstore") + func prefixRejectsLexical() { + let url = URL(string: "https://example.com/appstore/foo")! + let result = URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.withPrefix, + tabType: TestTab.self, destinationType: TestDestination.self + ) + #expect(result == nil) + } + + @Test("pathPrefix rejects /application") + func prefixRejectsAdjacent() { + let url = URL(string: "https://example.com/application/foo")! + let result = URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.withPrefix, + tabType: TestTab.self, destinationType: TestDestination.self + ) + #expect(result == nil) + } + + @Test("pathPrefix accepts trailing slash on URL: /app/") + func prefixAcceptsTrailingSlashInUrl() throws { + let url = URL(string: "https://example.com/app/?tab=home")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.withPrefix, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.destinations.isEmpty) + #expect(result.tab == .home) + } + + @Test("Missing prefix not required when config has none") + func noPrefix() throws { + let url = URL(string: "https://example.com/home")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.destinations == [.home]) + } + + // MARK: - tab resolution + + @Test("Multi-value tab query: LAST wins") + func tabLastWins() throws { + let url = URL(string: "https://example.com/home?tab=home&tab=profile")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.tab == .profile) + } + + @Test("Unknown tab value → tab nil") + func unknownTab() throws { + let url = URL(string: "https://example.com/home?tab=nonexistent")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + )) + #expect(result.tab == nil) + } + + // MARK: - Destination mapping + + @Test("Unmapped segment → nil") + func unmappedSegment() { + let url = URL(string: "https://example.com/unknown")! + let result = URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + ) + #expect(result == nil) + } + + @Test("Missing required query param → nil") + func missingRequiredParam() { + // .detail requires ?id= + let url = URL(string: "https://example.com/detail")! + let result = URLNavigationHelper.parseUniversalLink( + url, config: TestConfigs.basic, + tabType: TestTab.self, destinationType: TestDestination.self + ) + #expect(result == nil) + } + + // MARK: - Inline (non-config) overload + + @Test("Inline overload mirrors config overload") + func inlineOverload() throws { + let url = URL(string: "https://example.com/home")! + let result = try #require(URLNavigationHelper.parseUniversalLink( + url, + allowedHosts: ["example.com"], + tabType: TestTab.self, + destinationType: TestDestination.self + )) + #expect(result.destinations == [.home]) + } +} diff --git a/Tests/AppRouterPlusTests/RouterUniversalLinkTests.swift b/Tests/AppRouterPlusTests/RouterUniversalLinkTests.swift new file mode 100644 index 0000000..0eff170 --- /dev/null +++ b/Tests/AppRouterPlusTests/RouterUniversalLinkTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import AppRouterPlus + +@Suite("Router + Universal Links") +@MainActor +struct RouterUniversalLinkTests { + + func makeRouter() -> Router { + Router(initialTab: .home) + } + + // MARK: - Inline overload + + @Test("Inline: invalid URL returns false") + func inlineInvalid() { + let router = makeRouter() + let url = URL(string: "https://other.com/home")! + let ok = router.navigate( + toUniversalLink: url, + allowedHosts: ["example.com"] + ) + #expect(ok == false) + } + + @Test("Inline: valid URL navigates and returns true") + func inlineValid() { + let router = makeRouter() + let url = URL(string: "https://example.com/home?tab=profile")! + let ok = router.navigate( + toUniversalLink: url, + allowedHosts: ["example.com"], + deepPush: false // synchronous path for assertion + ) + #expect(ok == true) + #expect(router.selectedTab == .profile) + #expect(router[pathFor: .profile] == [.home]) + } + + // MARK: - Config overload + + @Test("Config: valid URL navigates") + func configValid() { + let router = makeRouter() + let url = URL(string: "https://example.com/home")! + let ok = router.navigate( + toUniversalLink: url, + config: TestConfigs.basic, + deepPush: false + ) + #expect(ok == true) + #expect(router[pathFor: .home] == [.home]) + } + + // MARK: - deepPush behavior + + @Test("deepPush true + policy .replace → async navigation, returns true immediately") + func deepPushAsync() async { + let router = makeRouter() + let url = URL(string: "https://example.com/home")! + let ok = router.navigate( + toUniversalLink: url, + config: TestConfigs.basic, + policy: .replace, + deepPush: true + ) + #expect(ok == true) + // Wait for background Task to push first destination (15ms step in deepPushTo) + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + #expect(router[pathFor: .home] == [.home]) + } + + @Test("deepPush true + policy .append → ignored, synchronous navigation") + func deepPushIgnoredWithAppend() { + let router = makeRouter() + let url = URL(string: "https://example.com/home")! + let ok = router.navigate( + toUniversalLink: url, + config: TestConfigs.basic, + policy: .append, + deepPush: true // ignored + ) + #expect(ok == true) + #expect(router[pathFor: .home] == [.home]) // immediate, no async + } +} diff --git a/Tests/AppRouterPlusTests/SimpleRouterURLTests.swift b/Tests/AppRouterPlusTests/SimpleRouterURLTests.swift new file mode 100644 index 0000000..b1c338f --- /dev/null +++ b/Tests/AppRouterPlusTests/SimpleRouterURLTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +@testable import AppRouterPlus + +@Suite("SimpleRouter + URLs") +@MainActor +struct SimpleRouterURLTests { + + func makeRouter() -> SimpleRouter { + SimpleRouter() + } + + // MARK: - Custom-scheme deeplink + + @Test("Custom scheme: valid URL navigates") + func customSchemeValid() { + let router = makeRouter() + let url = URL(string: "myapp://home")! + let ok = router.navigate(to: url) + #expect(ok == true) + #expect(router.path == [.home]) + } + + @Test("Custom scheme: unmapped → false") + func customSchemeInvalid() { + let router = makeRouter() + let url = URL(string: "myapp://nonexistent")! + let ok = router.navigate(to: url) + #expect(ok == false) + #expect(router.path.isEmpty) + } + + @Test("Custom scheme: tab query ignored (SimpleRouter has no tabs)") + func customSchemeIgnoresTab() { + let router = makeRouter() + let url = URL(string: "myapp://home?tab=anything")! + let ok = router.navigate(to: url) + #expect(ok == true) + #expect(router.path == [.home]) + } + + // MARK: - Universal Link + + @Test("UL config overload: valid URL navigates") + func ulConfigValid() { + let router = makeRouter() + let url = URL(string: "https://example.com/home")! + let ok = router.navigate(toUniversalLink: url, config: TestConfigs.basic) + #expect(ok == true) + #expect(router.path == [.home]) + } + + @Test("UL inline overload: valid URL navigates") + func ulInlineValid() { + let router = makeRouter() + let url = URL(string: "https://example.com/home")! + let ok = router.navigate( + toUniversalLink: url, + allowedHosts: ["example.com"] + ) + #expect(ok == true) + #expect(router.path == [.home]) + } + + @Test("UL: invalid host → false") + func ulInvalidHost() { + let router = makeRouter() + let url = URL(string: "https://other.com/home")! + let ok = router.navigate(toUniversalLink: url, config: TestConfigs.basic) + #expect(ok == false) + } + + @Test("UL: tab query ignored (SimpleRouter has no tabs)") + func ulIgnoresTab() { + let router = makeRouter() + let url = URL(string: "https://example.com/home?tab=anything")! + let ok = router.navigate(toUniversalLink: url, config: TestConfigs.basic) + #expect(ok == true) + #expect(router.path == [.home]) + } +} diff --git a/Tests/AppRouterPlusTests/SmokeTest.swift b/Tests/AppRouterPlusTests/SmokeTest.swift new file mode 100644 index 0000000..4debcf7 --- /dev/null +++ b/Tests/AppRouterPlusTests/SmokeTest.swift @@ -0,0 +1,11 @@ +import Testing +@testable import AppRouterPlus + +@Suite("Smoke") +struct SmokeTests { + + @Test("Test target compiles and runs") + func smoke() { + #expect(TestTab.allCases.count == 2) + } +} diff --git a/Tests/AppRouterPlusTests/TestFixtures.swift b/Tests/AppRouterPlusTests/TestFixtures.swift new file mode 100644 index 0000000..bfa1026 --- /dev/null +++ b/Tests/AppRouterPlusTests/TestFixtures.swift @@ -0,0 +1,55 @@ +import Foundation +@testable import AppRouterPlus + +// MARK: - Tabs + +enum TestTab: String, TabType, CaseIterable { + case home, profile +} + +// MARK: - Destinations (single-segment style — works with library's per-segment parse loop) + +enum TestDestination: DeepLinkableDestination { + case home + case detail(id: String) + case profile(userId: String) + + static func path(for destination: TestDestination) -> String { + switch destination { + case .home: return "home" + case .detail: return "detail" + case .profile: return "profile" + } + } + + static func from(path: String, fullPath: [String], parameters: [String: [String]]) -> TestDestination? { + switch path { + case "home": + return .home + case "detail": + guard let id = parameters["id"]?.last else { return nil } + return .detail(id: id) + case "profile": + guard let uid = parameters["userId"]?.last else { return nil } + return .profile(userId: uid) + default: + return nil + } + } +} + +// MARK: - Sheets + +enum TestSheet: String, SheetType { + case settings + var id: String { rawValue } +} + +// MARK: - Configs + +enum TestConfigs { + static let basic = UniversalLinkConfig(allowedHosts: ["example.com"]) + static let withPrefix = UniversalLinkConfig(allowedHosts: ["example.com"], pathPrefix: "/app") + static let multiHost = UniversalLinkConfig(allowedHosts: ["example.com", "example.org"]) + static let httpAllowed = UniversalLinkConfig(allowedHosts: ["example.com"], allowedSchemes: ["https", "http"]) +} diff --git a/Tests/AppRouterPlusTests/UniversalLinkConfigTests.swift b/Tests/AppRouterPlusTests/UniversalLinkConfigTests.swift new file mode 100644 index 0000000..2f6e5bc --- /dev/null +++ b/Tests/AppRouterPlusTests/UniversalLinkConfigTests.swift @@ -0,0 +1,72 @@ +import Testing +@testable import AppRouterPlus + +@Suite("UniversalLinkConfig") +struct UniversalLinkConfigTests { + + @Test("Default scheme is https") + func defaultScheme() { + let config = UniversalLinkConfig(allowedHosts: ["example.com"]) + #expect(config.allowedSchemes == ["https"]) + } + + @Test("Hosts normalized to lowercase") + func hostsLowercased() { + let config = UniversalLinkConfig(allowedHosts: ["Example.COM", "Foo.BAR"]) + #expect(config.allowedHosts == ["example.com", "foo.bar"]) + } + + @Test("Schemes normalized to lowercase") + func schemesLowercased() { + let config = UniversalLinkConfig( + allowedHosts: ["example.com"], + allowedSchemes: ["HTTPS", "Http"] + ) + #expect(config.allowedSchemes == ["https", "http"]) + } + + @Test("Trailing slash in pathPrefix stripped") + func trailingSlashStripped() { + let config = UniversalLinkConfig(allowedHosts: ["example.com"], pathPrefix: "/app/") + #expect(config.pathPrefix == "/app") + } + + @Test("Multiple trailing slashes stripped") + func multipleTrailingSlashesStripped() { + let config = UniversalLinkConfig(allowedHosts: ["example.com"], pathPrefix: "/app///") + #expect(config.pathPrefix == "/app") + } + + @Test("Single slash pathPrefix preserved") + func singleSlashPreserved() { + let config = UniversalLinkConfig(allowedHosts: ["example.com"], pathPrefix: "/") + #expect(config.pathPrefix == "/") + } + + @Test("Nil pathPrefix preserved") + func nilPrefixPreserved() { + let config = UniversalLinkConfig(allowedHosts: ["example.com"]) + #expect(config.pathPrefix == nil) + } + + @Test("Empty hosts accepted (will fail validation at parse time)") + func emptyHostsAccepted() { + let config = UniversalLinkConfig(allowedHosts: []) + #expect(config.allowedHosts.isEmpty) + } + + @Test("Hashable: same params == same hash") + func hashable() { + let a = UniversalLinkConfig(allowedHosts: ["example.com"], pathPrefix: "/app") + let b = UniversalLinkConfig(allowedHosts: ["example.com"], pathPrefix: "/app") + #expect(a == b) + #expect(a.hashValue == b.hashValue) + } + + @Test("Hashable: different prefix → different value") + func hashableInequality() { + let a = UniversalLinkConfig(allowedHosts: ["example.com"], pathPrefix: "/app") + let b = UniversalLinkConfig(allowedHosts: ["example.com"], pathPrefix: "/p") + #expect(a != b) + } +}