diff --git a/README.md b/README.md index 8f5868d..3a2f849 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ The `status()` method returns an object with the following properties: | `type` | string | Connection type: `wifi`, `cellular`, `ethernet`, or `unknown` | | `isExpensive` | boolean | Whether connection is metered (e.g., cellular data) | | `isConstrained` | boolean | Whether Low Data Mode is enabled (iOS only) | +| `error` | string | Present only when the status could not be determined. The other fields are then placeholders, not an observation — treat the connection as unknown rather than offline | ## Examples @@ -119,7 +120,8 @@ async function checkBeforeDownload() { - Requires `ACCESS_NETWORK_STATE` permission (added automatically) ### iOS -- Uses `NWPathMonitor` from Network framework +- Uses one long-lived `NWPathMonitor` from Network framework, started on the first call and answered from its latest path, so calls after the first cost no round trip to the network daemon +- The first call waits up to 0.5s for the monitor's first path; if none arrives it returns `error` instead of guessing `connected: false` - `isConstrained` reflects Low Data Mode setting - No special permissions required diff --git a/resources/ios/NetworkFunctions.swift b/resources/ios/NetworkFunctions.swift index d00af05..7e712c2 100644 --- a/resources/ios/NetworkFunctions.swift +++ b/resources/ios/NetworkFunctions.swift @@ -7,6 +7,68 @@ import Network /// Namespace: "Network.*" enum NetworkFunctions { + // MARK: - Path Observation + + /// The device's current network path, kept current in the background. + /// + /// `NWPathMonitor` is a push API and `BridgeFunction.execute` is + /// synchronous, so the two are bridged by holding the latest path here and + /// answering from it. The monitor runs for the life of the process rather + /// than being started per call: starting one costs a round trip through + /// the network daemon before it delivers anything, which would put that + /// cost on every call and still answer late. + /// + /// The one call that can arrive before the first path does is the first + /// one, so that call — and only that call — waits briefly for it. + private final class PathObserver { + static let shared = PathObserver() + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.nativephp.network.path") + private let lock = NSLock() + private let firstPath = DispatchSemaphore(value: 0) + + private var latest: NWPath? + private var signalled = false + + private init() { + monitor.pathUpdateHandler = { [weak self] path in + guard let self else { return } + + self.lock.lock() + self.latest = path + let isFirst = !self.signalled + self.signalled = true + self.lock.unlock() + + if isFirst { + self.firstPath.signal() + } + } + + monitor.start(queue: queue) + } + + /// The latest path, waiting up to `timeout` for the first one only. + /// Returns nil if none has arrived yet. + func currentPath(waitingUpTo timeout: TimeInterval) -> NWPath? { + lock.lock() + let known = latest + lock.unlock() + + if let known { + return known + } + + _ = firstPath.wait(timeout: .now() + timeout) + + lock.lock() + defer { lock.unlock() } + + return latest + } + } + // MARK: - Network.Status /// Get the current network connection status @@ -16,45 +78,48 @@ enum NetworkFunctions { /// - type: string - Connection type (wifi, cellular, ethernet, unknown) /// - isExpensive: boolean - Whether connection is metered/cellular /// - isConstrained: boolean - Whether Low Data Mode is enabled + /// - error: string - Present only when no path is known yet; the other + /// fields are then placeholders, not an observation that the device + /// is offline class Status: BridgeFunction { + /// Only ever paid once, before the monitor's first delivery. + private static let firstPathTimeout: TimeInterval = 0.5 + func execute(parameters: [String: Any]) throws -> [String: Any] { - let monitor = NWPathMonitor() - let semaphore = DispatchSemaphore(value: 0) - var result: [String: Any] = [ - "connected": false, - "type": "unknown", - "isExpensive": false, - "isConstrained": false - ] + guard let path = PathObserver.shared.currentPath(waitingUpTo: Status.firstPathTimeout) else { + return [ + "connected": false, + "type": "unknown", + "isExpensive": false, + "isConstrained": false, + "error": "Network path not yet determined" + ] + } - monitor.pathUpdateHandler = { path in - let isConnected = path.status == .satisfied - result["connected"] = isConnected - result["isExpensive"] = path.isExpensive - result["isConstrained"] = path.isConstrained - - // Determine connection type - var connectionType = "unknown" - if path.usesInterfaceType(.wifi) { - connectionType = "wifi" - } else if path.usesInterfaceType(.cellular) { - connectionType = "cellular" - } else if path.usesInterfaceType(.wiredEthernet) { - connectionType = "ethernet" - } - result["type"] = connectionType + return [ + "connected": path.status == .satisfied, + "type": Status.interfaceType(of: path), + "isExpensive": path.isExpensive, + "isConstrained": path.isConstrained + ] + } - semaphore.signal() + /// A path can use several interfaces at once; they are checked in the + /// order that answers "what is this connection, to a person". + private static func interfaceType(of path: NWPath) -> String { + if path.usesInterfaceType(.wifi) { + return "wifi" } - let queue = DispatchQueue(label: "NetworkMonitor") - monitor.start(queue: queue) + if path.usesInterfaceType(.cellular) { + return "cellular" + } - // Wait for the first update (with timeout) - _ = semaphore.wait(timeout: .now() + 2.0) - monitor.cancel() + if path.usesInterfaceType(.wiredEthernet) { + return "ethernet" + } - return result + return "unknown" } } }