| 1 | import Foundation |
| 2 | import Network |
| 3 | import Observation |
| 4 | #if canImport(UIKit) |
| 5 | import UIKit |
| 6 | #elseif canImport(AppKit) |
| 7 | import AppKit |
| 8 | #endif |
| 9 | |
| 10 | /// The network path as `NWPathMonitor` reports it. |
| 11 | public struct NativePath: Sendable, Equatable { |
| 12 | public enum Status: Sendable, Equatable { case satisfied, unsatisfied, requiresConnection } |
| 13 | public var status: Status |
| 14 | public var isExpensive: Bool |
| 15 | public var isConstrained: Bool |
| 16 | public init(status: Status, isExpensive: Bool = false, isConstrained: Bool = false) { |
| 17 | self.status = status; self.isExpensive = isExpensive; self.isConstrained = isConstrained |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | /// Where path updates come from: `NativeNetworkPathSource` in apps, a fake in tests. |
| 22 | public protocol NativePathSource: Sendable { |
| 23 | func start(_ update: @escaping @Sendable (NativePath) -> Void) |
| 24 | func cancel() |
| 25 | } |
| 26 | |
| 27 | public final class NativeNetworkPathSource: NativePathSource, @unchecked Sendable { |
| 28 | private let monitor = NWPathMonitor() |
| 29 | private let queue = DispatchQueue(label: "net.amadan.rastrillo.native.path") |
| 30 | public init() {} |
| 31 | public func start(_ update: @escaping @Sendable (NativePath) -> Void) { |
| 32 | monitor.pathUpdateHandler = { path in |
| 33 | let status: NativePath.Status |
| 34 | switch path.status { |
| 35 | case .satisfied: status = .satisfied |
| 36 | case .requiresConnection: status = .requiresConnection |
| 37 | default: status = .unsatisfied |
| 38 | } |
| 39 | update(NativePath(status: status, isExpensive: path.isExpensive, isConstrained: path.isConstrained)) |
| 40 | } |
| 41 | monitor.start(queue: queue) |
| 42 | } |
| 43 | public func cancel() { monitor.cancel() } |
| 44 | } |
| 45 | |
| 46 | /// Whether this device can reach a server, learned without probing: the path |
| 47 | /// from `NWPathMonitor`, and reachability per origin from real traffic |
| 48 | /// (`NativeHTTP`'s outcome observer). The Docs and Sheets streams are the |
| 49 | /// heartbeat; other apps learn on their next request. Nothing pings. |
| 50 | /// |
| 51 | /// Loops that retry wait on `wait(upTo:)`, which returns when the delay |
| 52 | /// elapses or connectivity changes, whichever is first, and never runs a |
| 53 | /// timer while the path is down: a restored network is acted on at once, and |
| 54 | /// an offline phone makes no timed attempts. |
| 55 | @MainActor @Observable public final class NativeConnectivity { |
| 56 | /// One per process. Starts `NWPathMonitor` on first use. |
| 57 | public static let shared = NativeConnectivity(source: NativeNetworkPathSource()) |
| 58 | |
| 59 | /// nil until the first update; unknown is treated as satisfied, so a |
| 60 | /// slow first update never blocks a request that would have worked. |
| 61 | public private(set) var path: NativePath? |
| 62 | /// true after a response from that origin, false after an offline-class |
| 63 | /// failure, absent (unknown) after a path change. |
| 64 | public private(set) var serverReachable: [URL: Bool] = [:] |
| 65 | |
| 66 | @ObservationIgnored private let source: NativePathSource |
| 67 | @ObservationIgnored private var subscribers: [UUID: AsyncStream<Void>.Continuation] = [:] |
| 68 | @ObservationIgnored private var foregroundObserver: NSObjectProtocol? |
| 69 | |
| 70 | public init(source: NativePathSource) { |
| 71 | self.source = source |
| 72 | source.start { [weak self] path in Task { @MainActor in self?.pathChanged(path) } } |
| 73 | #if canImport(UIKit) |
| 74 | let name = UIApplication.didBecomeActiveNotification |
| 75 | #elseif canImport(AppKit) |
| 76 | let name = NSApplication.didBecomeActiveNotification |
| 77 | #endif |
| 78 | foregroundObserver = NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in |
| 79 | Task { @MainActor in self?.foregrounded() } |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | deinit { |
| 84 | source.cancel() |
| 85 | if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) } |
| 86 | } |
| 87 | |
| 88 | public var isPathSatisfied: Bool { path.map { $0.status == .satisfied } ?? true } |
| 89 | |
| 90 | /// The path is down, or it is up and that server was just unreachable. |
| 91 | public func isOffline(_ origin: URL) -> Bool { !isPathSatisfied || serverReachable[origin] == false } |
| 92 | |
| 93 | /// Fires on a path change, on a server becoming reachable, and on return |
| 94 | /// to the foreground. Each access is a new subscription. |
| 95 | public var changes: AsyncStream<Void> { |
| 96 | let (stream, continuation) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1)) |
| 97 | let id = UUID() |
| 98 | subscribers[id] = continuation |
| 99 | continuation.onTermination = { [weak self] _ in Task { @MainActor in self?.subscribers[id] = nil } } |
| 100 | return stream |
| 101 | } |
| 102 | |
| 103 | public func record(_ outcome: NativeTransportOutcome, origin: URL) { |
| 104 | switch outcome { |
| 105 | case .responded: |
| 106 | let was = serverReachable[origin] |
| 107 | serverReachable[origin] = true |
| 108 | // A stream that opens must wake whatever is waiting to send (M2). |
| 109 | if was != true { notify() } |
| 110 | case .offline: |
| 111 | serverReachable[origin] = false |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | /// For `NativeHTTP(origin:credential:observer:)`. |
| 116 | public nonisolated func observer(for origin: URL) -> @Sendable (NativeTransportOutcome) -> Void { |
| 117 | { [weak self] outcome in Task { @MainActor in self?.record(outcome, origin: origin) } } |
| 118 | } |
| 119 | |
| 120 | public func foregrounded() { notify() } |
| 121 | |
| 122 | /// Returns after `delay` or on the next change, whichever is first, and |
| 123 | /// says which: true means connectivity changed, so a caller resets its |
| 124 | /// backoff. While the path is down it waits for the change only. Returns |
| 125 | /// false at once if the task is cancelled. |
| 126 | @discardableResult |
| 127 | public func wait(upTo delay: TimeInterval) async -> Bool { |
| 128 | let changed = changes |
| 129 | if !isPathSatisfied { |
| 130 | for await _ in changed { return true } |
| 131 | return false |
| 132 | } |
| 133 | return await withTaskGroup(of: Bool.self) { group in |
| 134 | group.addTask { try? await Task.sleep(nanoseconds: UInt64(max(0, delay) * 1_000_000_000)); return false } |
| 135 | group.addTask { for await _ in changed { return true }; return false } |
| 136 | let first = await group.next() ?? false |
| 137 | group.cancelAll() |
| 138 | return first |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func pathChanged(_ new: NativePath) { |
| 143 | guard new != path else { return } |
| 144 | path = new |
| 145 | // What was learned about servers belonged to the old path. |
| 146 | serverReachable = [:] |
| 147 | notify() |
| 148 | } |
| 149 | |
| 150 | private func notify() { for continuation in subscribers.values { continuation.yield() } } |
| 151 | } |
| 152 | |