diff --git a/CHANGELOG.md b/CHANGELOG.md index a77c95f..e455b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes are documented here. This project follows [Semantic Versioni ## [Unreleased] +## [2.4.6] - 2026-07-18 + +### Added + +- Added `ModuleRouteCoordinator` for per-scene serialized route handling, exact-URL deduplication, priority ordering, bounded queues, expiration, and policy revalidation before execution. +- Added coordinator tests and updated the demo plus English and Chinese guides with a plain-language concurrency model and integration example. + ## [2.4.5] - 2026-07-18 ### Added diff --git a/README.md b/README.md index 4f8aec6..ce264be 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,47 @@ Swift cannot discover unlinked packages at runtime. With two or more Feature Pac The registry rejects duplicate module IDs, a route returned by the wrong module, and push/sheet/full-screen routes without a destination. `ModuleRoutePolicy` lets the App Shell enforce contract versions, feature flags, authorization, and permitted presentation styles without coupling Feature Packages to those systems. Use `onFailure` to log rejected URLs and `onEvent` for privacy-conscious telemetry: each event includes a trace ID, outcome, route metadata, and no query values. A router has one active modal route: a new modal replaces the previous one, while push and tab routes dismiss the active modal before navigating. Repeated pushes of the same route are idempotent. +### When several links arrive at once + +On a busy app, a push notification, a Universal Link, and a button tap can all ask to navigate at nearly the same moment. Applying each one immediately makes the final screen depend on timing, and can make SwiftUI replace a sheet while it is still animating. + +`ModuleRouteCoordinator` is the scene's small waiting line. Give it the same router and registry, then install it instead of the direct handler. It gathers requests that arrive together, keeps only one copy of the exact same URL, and applies routes one at a time. This is deliberately not a login manager: the app still decides whether a route is authorized and which priority it deserves. + +```swift +@State private var router: ModuleRouter +@State private var routeCoordinator: ModuleRouteCoordinator + +init() { + let router = ModuleRouter() + _router = State(initialValue: router) + _routeCoordinator = State(initialValue: ModuleRouteCoordinator( + router: router, + registry: AppModules.registry, + allowedHosts: ["example.com"] + )) +} + +var body: some Scene { + WindowGroup { + RouterHost(router: router) { AppTabs(router: router) } destination: { + AppModules.registry.destination(for: $0) + } + .moduleLinkRouting(coordinator: routeCoordinator) + } +} +``` + +Its default rules are intentionally easy to reason about: + +- One scene has one coordinator; separate windows do not block each other. +- The same URL already waiting or being shown is merged instead of opening twice. +- `critical` goes before `external`, then `userInitiated`, then `background`. Requests at the same level keep arrival order. +- At most ten routes wait. When full, a higher-priority route replaces the oldest lower-priority one; otherwise the new route is refused. +- A waiting route expires after 30 seconds by default. That avoids unexpectedly opening an old notification after the user has moved on. +- The route is checked once on arrival and again immediately before navigation, so a policy or circuit-breaker change while it waits is still respected. + +For app-owned calls, use `route(_:priority:expiresAt:)`; external `openURL` and Universal Link requests use `.external` automatically. Queue outcomes are sent through the same observability hooks: `queue.duplicate_merged`, `queue.full`, and `queue.request_expired`. Tune the limits with `ModuleRouteCoordinatorConfiguration`, but keep the coordinator at the scene root rather than creating one per button. + ## Production governance ### Remote policy and emergency circuit breaker diff --git a/README.zh-CN.md b/README.zh-CN.md index 54cd7c0..32ca80d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -189,6 +189,47 @@ Swift 无法在运行时发现未链接的 Package。存在两个或更多 Featu 注册表会拒绝重复 module ID、由错误模块返回的 route,以及没有 destination 的 push/sheet/full-screen route。`ModuleRoutePolicy` 让 App Shell 在不耦合 Feature Package 的前提下执行协议版本、Feature 开关、权限和允许的展示方式。使用 `onFailure` 记录被拒绝的 URL,使用 `onEvent` 接入隐私友好的遥测:事件包含 trace ID、处理结果和 route 元数据,不包含 query value。Router 同一时刻只保留一个模态 route:新的模态 route 会替换旧的;push 和 tab route 会先关闭当前模态展示再导航;重复 push 相同 route 是幂等的。 +### 多条链接同时到达时怎么办 + +实际 App 很容易碰到:推送通知、Universal Link 和用户点击,几乎同时要求跳转。每一条都立刻执行,最后停在哪个页面就会看运气;SwiftUI 还可能在 sheet 动画尚未结束时又被要求换页面。 + +`ModuleRouteCoordinator` 就是每个场景前面的“小排队员”。把它和 router、registry 放在一起,在根部安装它,协调器会先收集同一时刻到达的请求,合并完全相同的 URL,然后一条一条跳转。它**不是**登录管理器:是否有权限、哪条业务更重要,仍由 App 自己决定。 + +```swift +@State private var router: ModuleRouter +@State private var routeCoordinator: ModuleRouteCoordinator + +init() { + let router = ModuleRouter() + _router = State(initialValue: router) + _routeCoordinator = State(initialValue: ModuleRouteCoordinator( + router: router, + registry: AppModules.registry, + allowedHosts: ["example.com"] + )) +} + +var body: some Scene { + WindowGroup { + RouterHost(router: router) { AppTabs(router: router) } destination: { + AppModules.registry.destination(for: $0) + } + .moduleLinkRouting(coordinator: routeCoordinator) + } +} +``` + +默认规则很直白: + +- 一个场景一个协调器;两个窗口互不堵塞。 +- 同一条 URL 已在等待或正在处理时,合并成一次,不会连续打开两次。 +- 优先级顺序是 `critical`、`external`、`userInitiated`、`background`;同级按到达顺序处理。 +- 最多等待 10 条。队列满时,更高优先级的新请求会挤掉最早、且优先级更低的请求;否则新请求被拒绝。 +- 默认等待超过 30 秒就过期,避免用户已经离开后又突然打开一条旧通知。 +- 入队时检查一次、真正跳转前再检查一次;所以等待期间策略或熔断开关改变,仍然会生效。 + +App 主动发起的跳转可调用 `route(_:priority:expiresAt:)`;外部 `openURL` 和 Universal Link 默认使用 `.external`。队列的结果仍走原有可观测性:`queue.duplicate_merged`、`queue.full`、`queue.request_expired`。可通过 `ModuleRouteCoordinatorConfiguration` 调整限制,但协调器应放在场景根部,不要每个按钮各建一个。 + ## 生产治理 ### 远程策略与紧急熔断 diff --git a/Sources/URLRouter/RouteCoordinator.swift b/Sources/URLRouter/RouteCoordinator.swift new file mode 100644 index 0000000..4bf023c --- /dev/null +++ b/Sources/URLRouter/RouteCoordinator.swift @@ -0,0 +1,291 @@ +// +// RouteCoordinator.swift +// URLRouter +// +// Copyright (c) 2026 relaxfinger +// SPDX-License-Identifier: MIT +// + +import Foundation +import SwiftUI + +/// The importance of a route request when several requests arrive together. +/// +/// The coordinator always finishes one request before starting the next one. +/// Higher-priority requests go first; requests with the same priority keep their +/// arrival order. The package does not assign login or business meaning to these +/// values: the app chooses the priority when it submits a route. +public enum ModuleRouteRequestPriority: Int, CaseIterable, Hashable, Sendable { + case background + case userInitiated + case external + case critical +} + +/// Queue limits for a scene's route coordinator. +public struct ModuleRouteCoordinatorConfiguration: Hashable, Sendable { + /// The maximum number of waiting routes. A route currently being applied is not counted. + public let maximumPendingRequests: Int + /// How long a request may wait before it is discarded. + public let defaultTimeToLive: TimeInterval + /// The pause between route applications, allowing SwiftUI to settle one transition first. + public let transitionDelay: Duration + + public init( + maximumPendingRequests: Int = 10, + defaultTimeToLive: TimeInterval = 30, + transitionDelay: Duration = .milliseconds(350) + ) { + self.maximumPendingRequests = max(1, maximumPendingRequests) + self.defaultTimeToLive = max(0, defaultTimeToLive) + self.transitionDelay = transitionDelay + } + + /// The production defaults: ten waiting routes, a 30-second lifetime, and a short transition pause. + public static let standard = ModuleRouteCoordinatorConfiguration() +} + +/// Why a queued route was not applied. +public enum ModuleRouteCoordinatorError: Error, Equatable, Sendable, LocalizedError { + case requestExpired + case queueFull + + public var errorDescription: String? { + switch self { + case .requestExpired: "The route request expired before it could be displayed." + case .queueFull: "The route queue is full." + } + } +} + +/// Serializes URL routing for one scene. +/// +/// Create one coordinator beside one `ModuleRouter`, normally in a SwiftUI +/// `@State` property. It validates a route before queuing it, merges an exact +/// duplicate URL, orders waiting requests by priority, and validates the route +/// once more immediately before applying it. The latter check ensures a policy +/// change made while a request waits still takes effect. +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +@MainActor +public final class ModuleRouteCoordinator { + private struct PendingRequest { + let url: URL + let host: String + let link: UniversalLink + let presentation: ResolvedModuleRoute + let priority: ModuleRouteRequestPriority + let sequence: UInt64 + let expiresAt: Date + } + + private let router: ModuleRouter + private let registry: ModuleRouteRegistry + private let allowedHosts: Set + private let policy: ModuleRoutePolicy + private let policyStore: ModuleRoutePolicyStore? + private let observability: ModuleRouteObservability? + private let onFailure: (URL, Error) -> Void + private let onEvent: (ModuleRouteEvent) -> Void + private let configuration: ModuleRouteCoordinatorConfiguration + + private var pendingRequests: [PendingRequest] = [] + private var activeURL: URL? + private var nextSequence: UInt64 = 0 + private var drainTask: Task? + + /// The number of routes waiting to be applied in this scene. + public var pendingRequestCount: Int { pendingRequests.count } + + /// Creates a coordinator for exactly one router and one app scene. + public init( + router: ModuleRouter, + registry: ModuleRouteRegistry, + allowedHosts: Set, + policy: ModuleRoutePolicy = .permissive, + policyStore: ModuleRoutePolicyStore? = nil, + observability: ModuleRouteObservability? = nil, + configuration: ModuleRouteCoordinatorConfiguration = .standard, + onFailure: @escaping (URL, Error) -> Void = { _, _ in }, + onEvent: @escaping (ModuleRouteEvent) -> Void = { _ in } + ) { + self.router = router + self.registry = registry + self.allowedHosts = allowedHosts + self.policy = policy + self.policyStore = policyStore + self.observability = observability + self.configuration = configuration + self.onFailure = onFailure + self.onEvent = onEvent + } + + /// Validates and submits a route for serialized execution. + /// + /// A duplicate of a waiting or active URL is accepted but not added again. + /// For an external URL that does not belong to an allowed host, this returns + /// `.systemAction` so SwiftUI can handle it normally. + @discardableResult + public func route( + _ url: URL, + priority: ModuleRouteRequestPriority = .external, + expiresAt: Date? = nil + ) -> OpenURLAction.Result { + guard let host = routeHost(for: url) else { + emit(outcome: .systemAction, host: nil) + return .systemAction + } + + do { + let link = try UniversalLink(url: url, allowedHosts: allowedHosts) + let presentation = try registry.resolve(link) + try validate(link, presentation: presentation) + discardExpiredRequests() + + guard activeURL != url, !pendingRequests.contains(where: { $0.url == url }) else { + emit(outcome: .handled, host: host, presentation: presentation, failureCode: "queue.duplicate_merged") + return .handled + } + + let expiry = expiresAt ?? Date().addingTimeInterval(configuration.defaultTimeToLive) + guard expiry > Date() else { + discard(url, host: host, presentation: presentation, error: ModuleRouteCoordinatorError.requestExpired) + return .discarded + } + + let request = PendingRequest( + url: url, + host: host, + link: link, + presentation: presentation, + priority: priority, + sequence: nextSequence, + expiresAt: expiry + ) + nextSequence &+= 1 + guard enqueue(request) else { return .discarded } + scheduleDrain() + return .handled + } catch { + discard(url, host: host, error: error) + return .discarded + } + } + + private func enqueue(_ request: PendingRequest) -> Bool { + if pendingRequests.count >= configuration.maximumPendingRequests, + let lowestIndex = pendingRequests.indices.min(by: { lhs, rhs in + let left = pendingRequests[lhs] + let right = pendingRequests[rhs] + return left.priority.rawValue == right.priority.rawValue + ? left.sequence < right.sequence + : left.priority.rawValue < right.priority.rawValue + }) { + let lowest = pendingRequests[lowestIndex] + guard request.priority.rawValue > lowest.priority.rawValue else { + discard(request.url, host: request.host, presentation: request.presentation, error: ModuleRouteCoordinatorError.queueFull) + return false + } + pendingRequests.remove(at: lowestIndex) + discard(lowest.url, host: lowest.host, presentation: lowest.presentation, error: ModuleRouteCoordinatorError.queueFull) + } + pendingRequests.append(request) + return true + } + + private func scheduleDrain() { + guard drainTask == nil else { return } + drainTask = Task { @MainActor [weak self] in + // Batch URLs delivered in the same run-loop turn before choosing a winner. + await Task.yield() + await self?.drain() + } + } + + private func drain() async { + defer { drainTask = nil } + while !pendingRequests.isEmpty { + discardExpiredRequests() + guard let index = nextRequestIndex() else { break } + let request = pendingRequests.remove(at: index) + activeURL = request.url + + do { + try validate(request.link, presentation: request.presentation) + router.apply(request.presentation) + emit(outcome: .handled, host: request.host, presentation: request.presentation) + } catch { + discard(request.url, host: request.host, presentation: request.presentation, error: error) + } + activeURL = nil + + if !pendingRequests.isEmpty { + try? await Task.sleep(for: configuration.transitionDelay) + } + } + } + + private func nextRequestIndex() -> Int? { + pendingRequests.indices.max { lhs, rhs in + let left = pendingRequests[lhs] + let right = pendingRequests[rhs] + return left.priority.rawValue == right.priority.rawValue + ? left.sequence > right.sequence + : left.priority.rawValue < right.priority.rawValue + } + } + + private func discardExpiredRequests() { + let now = Date() + let expired = pendingRequests.filter { $0.expiresAt <= now } + pendingRequests.removeAll { $0.expiresAt <= now } + for request in expired { + discard(request.url, host: request.host, presentation: request.presentation, error: ModuleRouteCoordinatorError.requestExpired) + } + } + + private func validate(_ link: UniversalLink, presentation: ResolvedModuleRoute) throws { + if let policyStore { + try policyStore.validate(link, presentation: presentation) + } else { + try policy.validate(link, presentation: presentation) + } + } + + private func routeHost(for url: URL) -> String? { + guard let host = URLComponents(url: url, resolvingAgainstBaseURL: false)?.host?.lowercased(), + allowedHosts.contains(where: { $0.lowercased() == host }) else { + return nil + } + return host + } + + private func discard( + _ url: URL, + host: String, + presentation: ResolvedModuleRoute? = nil, + error: Error + ) { + onFailure(url, error) + emit(outcome: .discarded, host: host, presentation: presentation, failure: error) + } + + private func emit( + outcome: ModuleRouteEventOutcome, + host: String?, + presentation: ResolvedModuleRoute? = nil, + failure: Error? = nil, + failureCode: String? = nil + ) { + let event = ModuleRouteEvent( + outcome: outcome, + host: host, + moduleID: presentation?.route.moduleID, + routeID: presentation?.route.routeID, + presentation: presentation?.presentation, + failureCode: failureCode ?? failure.map(moduleRouteFailureCode), + failureDescription: failure?.localizedDescription + ) + observability?.record(event) + onEvent(event) + } +} diff --git a/Sources/URLRouter/URLRouter.swift b/Sources/URLRouter/URLRouter.swift index ac6a830..b3414e7 100644 --- a/Sources/URLRouter/URLRouter.swift +++ b/Sources/URLRouter/URLRouter.swift @@ -264,47 +264,13 @@ public struct ModuleLinkRoutingModifier: ViewModifier { moduleID: presentation?.route.moduleID, routeID: presentation?.route.routeID, presentation: presentation?.presentation, - failureCode: failure.map(routeFailureCode), + failureCode: failure.map(moduleRouteFailureCode), failureDescription: failure?.localizedDescription ) observability?.record(event) onEvent(event) } - private func routeFailureCode(_ error: Error) -> String { - if let error = error as? ModuleRoutePolicyError { - return switch error { - case .missingContractVersion: "policy.missing_contract_version" - case .unsupportedContractVersion: "policy.unsupported_contract_version" - case .presentationNotAllowed: "policy.presentation_not_allowed" - case .moduleDisabled: "policy.module_disabled" - case .unauthorized: "policy.unauthorized" - case .routingSuspended: "policy.routing_suspended" - } - } - if let error = error as? ModuleRouteRegistryError { - return switch error { - case .duplicateModuleID: "registry.duplicate_module_id" - case .routeModuleMismatch: "registry.module_mismatch" - case .unavailableDestination: "registry.unavailable_destination" - } - } - if let error = error as? UniversalLinkError { - return switch error { - case .invalidURL: "link.invalid_url" - case .unsupportedScheme: "link.unsupported_scheme" - case .untrustedHost: "link.untrusted_host" - case .credentialsAreNotAllowed: "link.credentials_not_allowed" - case .unsupportedPort: "link.unsupported_port" - case .fragmentIsNotAllowed: "link.fragment_not_allowed" - case .invalidPathEncoding: "link.invalid_path_encoding" - case .duplicateQueryItem: "link.duplicate_query_item" - case .missingQueryValue: "link.missing_query_value" - case .unsupportedRoute: "link.unsupported_route" - } - } - return "route.unknown_failure" - } } @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) @@ -332,6 +298,65 @@ public extension View { onEvent: onEvent )) } + + @MainActor + /// Installs a scene-level coordinator that serializes concurrent route requests. + func moduleLinkRouting(coordinator: ModuleRouteCoordinator) -> some View { + modifier(ModuleRouteCoordinatorModifier(coordinator: coordinator)) + } +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +@MainActor +private struct ModuleRouteCoordinatorModifier: ViewModifier { + let coordinator: ModuleRouteCoordinator + + func body(content: Content) -> some View { + content + .environment(\.openURL, OpenURLAction { coordinator.route($0) }) + .onOpenURL { _ = coordinator.route($0) } + } +} + +func moduleRouteFailureCode(_ error: Error) -> String { + if let error = error as? ModuleRouteCoordinatorError { + return switch error { + case .requestExpired: "queue.request_expired" + case .queueFull: "queue.full" + } + } + if let error = error as? ModuleRoutePolicyError { + return switch error { + case .missingContractVersion: "policy.missing_contract_version" + case .unsupportedContractVersion: "policy.unsupported_contract_version" + case .presentationNotAllowed: "policy.presentation_not_allowed" + case .moduleDisabled: "policy.module_disabled" + case .unauthorized: "policy.unauthorized" + case .routingSuspended: "policy.routing_suspended" + } + } + if let error = error as? ModuleRouteRegistryError { + return switch error { + case .duplicateModuleID: "registry.duplicate_module_id" + case .routeModuleMismatch: "registry.module_mismatch" + case .unavailableDestination: "registry.unavailable_destination" + } + } + if let error = error as? UniversalLinkError { + return switch error { + case .invalidURL: "link.invalid_url" + case .unsupportedScheme: "link.unsupported_scheme" + case .untrustedHost: "link.untrusted_host" + case .credentialsAreNotAllowed: "link.credentials_not_allowed" + case .unsupportedPort: "link.unsupported_port" + case .fragmentIsNotAllowed: "link.fragment_not_allowed" + case .invalidPathEncoding: "link.invalid_path_encoding" + case .duplicateQueryItem: "link.duplicate_query_item" + case .missingQueryValue: "link.missing_query_value" + case .unsupportedRoute: "link.unsupported_route" + } + } + return "route.unknown_failure" } /// A reusable SwiftUI shell for push, sheet, and full-screen-cover routes. diff --git a/Tests/URLRouterTests/URLRouterTests.swift b/Tests/URLRouterTests/URLRouterTests.swift index c4e5c5e..401dc3b 100644 --- a/Tests/URLRouterTests/URLRouterTests.swift +++ b/Tests/URLRouterTests/URLRouterTests.swift @@ -243,6 +243,73 @@ final class URLRouterTests: XCTestCase { XCTAssertEqual(router.path, [route]) } + @MainActor + func testCoordinatorExecutesConcurrentRequestsByPriorityThenArrivalOrder() async throws { + let router = ModuleRouter() + let coordinator = ModuleRouteCoordinator( + router: router, + registry: ModuleRouteRegistry(modules: [contentModule]), + allowedHosts: ["example.com"], + configuration: ModuleRouteCoordinatorConfiguration(transitionDelay: .zero) + ) + + _ = coordinator.route(URL(string: "https://example.com/articles/background?presentation=push")!, priority: .background) + _ = coordinator.route(URL(string: "https://example.com/articles/tap?presentation=push")!, priority: .userInitiated) + _ = coordinator.route(URL(string: "https://example.com/articles/link?presentation=push")!, priority: .external) + _ = coordinator.route(URL(string: "https://example.com/articles/critical?presentation=push")!, priority: .critical) + + await waitForCoordinatorToBecomeIdle(coordinator) + + XCTAssertEqual(router.path.map { $0.parameters["id"] }, ["critical", "link", "tap", "background"]) + XCTAssertEqual(coordinator.pendingRequestCount, 0) + } + + @MainActor + func testCoordinatorMergesDuplicatesAndEvictsLowerPriorityRequestsWhenFull() async throws { + let router = ModuleRouter() + var events: [ModuleRouteEvent] = [] + let coordinator = ModuleRouteCoordinator( + router: router, + registry: ModuleRouteRegistry(modules: [contentModule]), + allowedHosts: ["example.com"], + configuration: ModuleRouteCoordinatorConfiguration(maximumPendingRequests: 1, transitionDelay: .zero), + onEvent: { events.append($0) } + ) + let background = URL(string: "https://example.com/articles/background?presentation=push")! + + _ = coordinator.route(background, priority: .background) + _ = coordinator.route(background, priority: .background) + XCTAssertEqual(coordinator.pendingRequestCount, 1) + _ = coordinator.route( + URL(string: "https://example.com/articles/critical?presentation=push")!, + priority: .critical + ) + + await waitForCoordinatorToBecomeIdle(coordinator) + + XCTAssertEqual(router.path.map { $0.parameters["id"] }, ["critical"]) + XCTAssertTrue(events.contains { $0.failureCode == "queue.duplicate_merged" }) + XCTAssertTrue(events.contains { $0.failureCode == "queue.full" }) + } + + @MainActor + func testCoordinatorDiscardsExpiredRequests() throws { + var failures: [Error] = [] + let coordinator = ModuleRouteCoordinator( + router: ModuleRouter(), + registry: ModuleRouteRegistry(modules: [contentModule]), + allowedHosts: ["example.com"], + onFailure: { _, error in failures.append(error) } + ) + + _ = coordinator.route( + URL(string: "https://example.com/articles/old?presentation=push")!, + expiresAt: Date().addingTimeInterval(-1) + ) + + XCTAssertEqual(failures.first as? ModuleRouteCoordinatorError, .requestExpired) + } + @MainActor private var contentModule: RouteModule { RouteModule(id: "content") { link in @@ -263,6 +330,18 @@ final class URLRouterTests: XCTestCase { try UniversalLink(url: try XCTUnwrap(URL(string: string)), allowedHosts: ["example.com"]) } + @MainActor + private func waitForCoordinatorToBecomeIdle(_ coordinator: ModuleRouteCoordinator) async { + for _ in 0..<100 { + if coordinator.pendingRequestCount == 0 { + await Task.yield() + return + } + await Task.yield() + } + XCTFail("The route coordinator did not drain its queue.") + } + @MainActor private final class RouteObserver: ModuleRouteObserving { var events: [ModuleRouteEvent] = [] diff --git a/URLRouter.xcodeproj/project.pbxproj b/URLRouter.xcodeproj/project.pbxproj index 9724d30..3de7b6e 100644 --- a/URLRouter.xcodeproj/project.pbxproj +++ b/URLRouter.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 6ED81CA3244C409800C385B2 /* URLRouter.h in Headers */ = {isa = PBXBuildFile; fileRef = 6ED81C95244C409800C385B2 /* URLRouter.h */; settings = {ATTRIBUTES = (Public, ); }; }; 6ED81CAD244C482900C385B2 /* UniversalLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED81CAC244C482900C385B2 /* UniversalLink.swift */; }; 6ED81CAF244C48B200C385B2 /* URLRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED81CAE244C48B200C385B2 /* URLRouter.swift */; }; + D3A000000000000000000101 /* RoutePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A000000000000000000102 /* RoutePolicy.swift */; }; + D3A000000000000000000103 /* RouteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A000000000000000000104 /* RouteCoordinator.swift */; }; D3A000000000000000000001 /* URLRouterDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A000000000000000000011 /* URLRouterDemoApp.swift */; }; D3A000000000000000000002 /* DemoViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A000000000000000000012 /* DemoViews.swift */; }; D3A000000000000000000005 /* DemoFeatureViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A000000000000000000015 /* DemoFeatureViews.swift */; }; @@ -40,6 +42,8 @@ 6ED81CA2244C409800C385B2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6ED81CAC244C482900C385B2 /* UniversalLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/URLRouter/UniversalLink.swift; sourceTree = SOURCE_ROOT; }; 6ED81CAE244C48B200C385B2 /* URLRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/URLRouter/URLRouter.swift; sourceTree = SOURCE_ROOT; }; + D3A000000000000000000102 /* RoutePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/URLRouter/RoutePolicy.swift; sourceTree = SOURCE_ROOT; }; + D3A000000000000000000104 /* RouteCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/URLRouter/RouteCoordinator.swift; sourceTree = SOURCE_ROOT; }; D3A000000000000000000010 /* URLRouterDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = URLRouterDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; D3A000000000000000000011 /* URLRouterDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRouterDemoApp.swift; sourceTree = ""; }; D3A000000000000000000012 /* DemoViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoViews.swift; sourceTree = ""; }; @@ -104,6 +108,8 @@ 6ED81C96244C409800C385B2 /* Info.plist */, 6ED81CAC244C482900C385B2 /* UniversalLink.swift */, 6ED81CAE244C48B200C385B2 /* URLRouter.swift */, + D3A000000000000000000102 /* RoutePolicy.swift */, + D3A000000000000000000104 /* RouteCoordinator.swift */, 6E35553D244E9EE100A3F4A7 /* URLRouter.modulemap */, ); path = URLRouter; @@ -282,6 +288,8 @@ files = ( 6ED81CAF244C48B200C385B2 /* URLRouter.swift in Sources */, 6ED81CAD244C482900C385B2 /* UniversalLink.swift in Sources */, + D3A000000000000000000101 /* RoutePolicy.swift in Sources */, + D3A000000000000000000103 /* RouteCoordinator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/URLRouterDemo/URLRouterDemoApp.swift b/URLRouterDemo/URLRouterDemoApp.swift index 43dffc4..309a2b9 100644 --- a/URLRouterDemo/URLRouterDemoApp.swift +++ b/URLRouterDemo/URLRouterDemoApp.swift @@ -16,9 +16,27 @@ import NavigationFeature @main /// The demo composes the same `RouterHost` API available to every supported platform. struct URLRouterDemoApp: App { - @State private var router = ModuleRouter() // One URLRouter navigation state per scene. - @State private var policySession = DemoPolicySession() // Owns URLRouter's local and remote route policy. - @State private var routeObserver = DemoRouteObserver() // Receives URLRouter route telemetry events. + @State private var router: ModuleRouter // One URLRouter navigation state per scene. + @State private var policySession: DemoPolicySession // Owns URLRouter's local and remote route policy. + @State private var routeObserver: DemoRouteObserver // Receives URLRouter route telemetry events. + @State private var routeCoordinator: ModuleRouteCoordinator // Serializes simultaneous route requests for this scene. + + init() { + let router = ModuleRouter() + let policySession = DemoPolicySession() + let routeObserver = DemoRouteObserver() + _router = State(initialValue: router) + _policySession = State(initialValue: policySession) + _routeObserver = State(initialValue: routeObserver) + _routeCoordinator = State(initialValue: ModuleRouteCoordinator( + router: router, + registry: DemoModules.registry, + allowedHosts: ["example.com"], + policyStore: policySession.store, + // Forward privacy-safe queue and routing events to the demo observer. + observability: ModuleRouteObservability(observers: [routeObserver]) + )) + } var body: some Scene { WindowGroup { @@ -34,15 +52,8 @@ struct URLRouterDemoApp: App { // Ask URLRouter's registry for the Feature-owned destination view. DemoModules.registry.destination(for: route) } - // Install URLRouter once at the scene root for openURL and Universal Links. - .moduleLinkRouting( - router: router, - registry: DemoModules.registry, - allowedHosts: ["example.com"], - policyStore: policySession.store, - // Send privacy-safe URLRouter events to the demo observer. - observability: ModuleRouteObservability(observers: [routeObserver]) - ) + // Install the coordinator once; it queues simultaneous openURL and Universal Link requests. + .moduleLinkRouting(coordinator: routeCoordinator) // Restore cached policy first, then refresh the demo's remote policy. .task { await policySession.bootstrapAndRefresh() } }