Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
127 changes: 96 additions & 31 deletions resources/ios/NetworkFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
}
}
}