creaturesurvive/localnetworkaccess
Check and request the Local Network privilege on iOS, iPadOS, visionOS and macOS, which Apple
Why
Every app that talks to devices on the LAN (casting, smart home, NAS, printers, game consoles) hits local network privacy. There's no authorizationStatus and no requestAuthorization (FB8711182). Most apps paper over this with a copy-pasted Bonjour hack that:
- shows the alert when you only wanted to check the status,
- can't tell "denied" from "the alert is still on screen",
- breaks when
NSBonjourServicesis missing an entry, and - never notices when the user flips the switch in Settings.
How it works
It follows Apple's TN3179:
- Probe: it connects a UDP socket to an address in the device's own subnet. That's a local
network operation, but connecting a UDP socket sends no packets. A denial shows up as NWPath.UnsatisfiedReason.localNetworkDenied; otherwise the connection becomes ready. The system retries the connection when the privilege changes, so the probe also follows later changes.
- Checking vs. asking: probing an undetermined privilege is what shows the alert, so
status() doesn't probe until the app has asked through request() (or called markAsRequested()). That flag is stored in UserDefaults. Deleting the app clears it along with the privilege itself.
- Waiting for the answer: while the alert is on screen the system reports "denied", so
request() watches app activity: - The alert deactivates the app. When the app becomes active again, the user has answered. - If no alert appears, the decision was made earlier and the result is final.
- Background: in the background the system denies local network operations silently and
records nothing, so request() doesn't probe there.
- Platforms without the privilege (tvOS, watchOS, macOS 14 and earlier) report
.notRequired.
Features
status(): current status, never prompts.request(): prompts if needed and waits for the answer (up to a 30-second timeout).statusUpdates(): anAsyncStreamthat re-checks on foreground and follows live changes.lastKnownStatus: the persisted last answer, for an instant initial UI.LocalNetworkAccessMonitor:@Observablewrapper withrequest()andopenSettings().configurationIssues(bonjourServices:): catches a missingNSLocalNetworkUsageDescription
and undeclared or malformed NSBonjourServices entries, which otherwise fail silently.
- Network.framework helpers:
- NWBrowser.State.isLocalNetworkDenied and NWListener.State.isLocalNetworkDenied - NWConnection.isBlockedByLocalNetworkPrivacy and NWPath.isLocalNetworkDenied - NWError.isLocalNetworkPolicyDenied (-65570) - URLError.mayBeLocalNetworkDenial
LocalAddress.isLocal(_:): whether a host is a local network destination (RFC 1918,
link-local, ULA, multicast, .local).
- The probe and app-activity sources are protocols, so your own logic can be unit tested.
SwiftUI
struct DevicesView: View {
@State private var localNetwork = LocalNetworkAccessMonitor()
var body: some View {
switch localNetwork.status {
case .notDetermined:
ContentUnavailableView {
Label("Find Devices", systemImage: "tv")
} actions: {
Button("Continue") { Task { await localNetwork.request() } }
}
case .denied:
Button("Allow Local Network Access in Settings") { localNetwork.openSettings() }
default:
DeviceList()
}
}
}Configuration
Add NSLocalNetworkUsageDescription to your app's Info.plist. If you use Bonjour, also list each service type in NSBonjourServices. Check both in debug builds:
#if DEBUG
for issue in LocalNetworkAccess.configurationIssues(bonjourServices: ["_googlecast._tcp"]) {
assertionFailure(issue.description)
}
#endifRaw multicast on iOS (SSDP, mDNS sockets) also needs the com.apple.developer.networking.multicast entitlement, which can't be checked at run time.
Installation
Add LocalNetworkAccess to your Package.swift:
dependencies: [
.package(url: "https://github.com/CreatureSurvive/LocalNetworkAccess.git", from: "1.0.0"),
],
targets: [
.target(name: "MyApp", dependencies: ["LocalNetworkAccess"]),
]Or in Xcode, choose File › Add Package Dependencies… and enter https://github.com/CreatureSurvive/LocalNetworkAccess.
Requirements
| Platform | Minimum | | --- | --- | | iOS | 17.0 | | macOS | 14.0 | | tvOS | 17.0 | | watchOS | 10.0 | | visionOS | 1.0 |
Swift 6.1 (Xcode 16.4) or later, in Swift 6 language mode. No third-party dependencies.
Testing
swift test covers:
- the request state machine (allow, deny, an answer arriving late, no alert, timeouts,
background), using a scripted probe and scripted app activity
- status streaming
- the configuration checks
- address classification
- subnet math
On macOS it also runs the real probe, which must report "allowed" because command-line tools always have local network access.
The simulator doesn't implement local network privacy. Example/ has an iOS/macOS demo app for checking the real alert on a device:
cd Example && xcodegen generate && open LocalNetworkDemo.xcodeprojLimitations
- The status can't be read before the app has requested it, because no API exists for that.
If other code in your app triggers the alert first, call markAsRequested() afterwards.
- On macOS, if the app isn't frontmost when the alert appears,
request()can't see the answer
until the app is activated. It returns .notDetermined at the timeout.
- With no Wi-Fi or Ethernet connection there's nothing to probe, so
status()returns
.unknown.
Changelog
See CHANGELOG.md. Releases follow Semantic Versioning.
Contributing
Issues and pull requests are welcome. Please run swift test before opening a pull request, and add tests for new behavior.
License
Available under the MIT license. See LICENSE for details.
Package Metadata
Repository: creaturesurvive/localnetworkaccess
Default branch: main
README: README.md