Contents

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 NSBonjourServices is 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(): an AsyncStream that re-checks on foreground and follows live changes.
  • lastKnownStatus: the persisted last answer, for an instant initial UI.
  • LocalNetworkAccessMonitor: @Observable wrapper with request() and openSettings().
  • configurationIssues(bonjourServices:): catches a missing NSLocalNetworkUsageDescription

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)
}
#endif

Raw 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.xcodeproj

Limitations

  • 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