import Foundation import Network import Observation #if canImport(UIKit) import UIKit #elseif canImport(AppKit) import AppKit #endif /// The network path as `NWPathMonitor` reports it. public struct NativePath: Sendable, Equatable { public enum Status: Sendable, Equatable { case satisfied, unsatisfied, requiresConnection } public var status: Status public var isExpensive: Bool public var isConstrained: Bool public init(status: Status, isExpensive: Bool = false, isConstrained: Bool = false) { self.status = status; self.isExpensive = isExpensive; self.isConstrained = isConstrained } } /// Where path updates come from: `NativeNetworkPathSource` in apps, a fake in tests. public protocol NativePathSource: Sendable { func start(_ update: @escaping @Sendable (NativePath) -> Void) func cancel() } public final class NativeNetworkPathSource: NativePathSource, @unchecked Sendable { private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "net.amadan.rastrillo.native.path") public init() {} public func start(_ update: @escaping @Sendable (NativePath) -> Void) { monitor.pathUpdateHandler = { path in let status: NativePath.Status switch path.status { case .satisfied: status = .satisfied case .requiresConnection: status = .requiresConnection default: status = .unsatisfied } update(NativePath(status: status, isExpensive: path.isExpensive, isConstrained: path.isConstrained)) } monitor.start(queue: queue) } public func cancel() { monitor.cancel() } } /// Whether this device can reach a server, learned without probing: the path /// from `NWPathMonitor`, and reachability per origin from real traffic /// (`NativeHTTP`'s outcome observer). The Docs and Sheets streams are the /// heartbeat; other apps learn on their next request. Nothing pings. /// /// Loops that retry wait on `wait(upTo:)`, which returns when the delay /// elapses or connectivity changes, whichever is first, and never runs a /// timer while the path is down: a restored network is acted on at once, and /// an offline phone makes no timed attempts. @MainActor @Observable public final class NativeConnectivity { /// One per process. Starts `NWPathMonitor` on first use. public static let shared = NativeConnectivity(source: NativeNetworkPathSource()) /// nil until the first update; unknown is treated as satisfied, so a /// slow first update never blocks a request that would have worked. public private(set) var path: NativePath? /// true after a response from that origin, false after an offline-class /// failure, absent (unknown) after a path change. public private(set) var serverReachable: [URL: Bool] = [:] @ObservationIgnored private let source: NativePathSource @ObservationIgnored private var subscribers: [UUID: AsyncStream.Continuation] = [:] @ObservationIgnored private var foregroundObserver: NSObjectProtocol? public init(source: NativePathSource) { self.source = source source.start { [weak self] path in Task { @MainActor in self?.pathChanged(path) } } #if canImport(UIKit) let name = UIApplication.didBecomeActiveNotification #elseif canImport(AppKit) let name = NSApplication.didBecomeActiveNotification #endif foregroundObserver = NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in Task { @MainActor in self?.foregrounded() } } } deinit { source.cancel() if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) } } public var isPathSatisfied: Bool { path.map { $0.status == .satisfied } ?? true } /// The path is down, or it is up and that server was just unreachable. public func isOffline(_ origin: URL) -> Bool { !isPathSatisfied || serverReachable[origin] == false } /// Fires on a path change, on a server becoming reachable, and on return /// to the foreground. Each access is a new subscription. public var changes: AsyncStream { let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) let id = UUID() subscribers[id] = continuation continuation.onTermination = { [weak self] _ in Task { @MainActor in self?.subscribers[id] = nil } } return stream } public func record(_ outcome: NativeTransportOutcome, origin: URL) { switch outcome { case .responded: let was = serverReachable[origin] serverReachable[origin] = true // A stream that opens must wake whatever is waiting to send (M2). if was != true { notify() } case .offline: serverReachable[origin] = false } } /// For `NativeHTTP(origin:credential:observer:)`. public nonisolated func observer(for origin: URL) -> @Sendable (NativeTransportOutcome) -> Void { { [weak self] outcome in Task { @MainActor in self?.record(outcome, origin: origin) } } } public func foregrounded() { notify() } /// Returns after `delay` or on the next change, whichever is first, and /// says which: true means connectivity changed, so a caller resets its /// backoff. While the path is down it waits for the change only. Returns /// false at once if the task is cancelled. @discardableResult public func wait(upTo delay: TimeInterval) async -> Bool { let changed = changes if !isPathSatisfied { for await _ in changed { return true } return false } return await withTaskGroup(of: Bool.self) { group in group.addTask { try? await Task.sleep(nanoseconds: UInt64(max(0, delay) * 1_000_000_000)); return false } group.addTask { for await _ in changed { return true }; return false } let first = await group.next() ?? false group.cancelAll() return first } } func pathChanged(_ new: NativePath) { guard new != path else { return } path = new // What was learned about servers belonged to the old path. serverReachable = [:] notify() } private func notify() { for continuation in subscribers.values { continuation.yield() } } }