rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import Network
3import Observation
4#if canImport(UIKit)
5import UIKit
6#elseif canImport(AppKit)
7import AppKit
8#endif
9
10/// The network path as `NWPathMonitor` reports it.
11public 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.
22public protocol NativePathSource: Sendable {
23 func start(_ update: @escaping @Sendable (NativePath) -> Void)
24 func cancel()
25}
26
27public 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