How to Build a Network Extension in SwiftUI
Add a Packet Tunnel Provider extension target to your project, subclass NEPacketTunnelProvider, then use NETunnelProviderManager from your container app to start, stop, and observe the tunnel. Wire the status up to SwiftUI via NEVPNStatusDidChange notifications.
// Container app — configure and connect
import NetworkExtension
func startVPN() async throws {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
let manager = managers.first ?? NETunnelProviderManager()
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = "com.example.app.tunnel"
proto.serverAddress = "vpn.example.com"
manager.protocolConfiguration = proto
manager.localizedDescription = "My VPN"
manager.isEnabled = true
try await manager.saveToPreferences()
try manager.connection.startVPNTunnel()
}
Full implementation
A Network Extension lives in two distinct binaries: the tunnel provider extension process and the container app. The provider subclasses NEPacketTunnelProvider and handles raw IP packets; the container app owns the NETunnelProviderManager lifecycle and exposes controls to the user. Below is a complete pair — the provider implementation first, followed by a SwiftUI manager view.
// ─── PacketTunnelProvider.swift (Extension target) ───────────────────────
import NetworkExtension
import os.log
private let log = Logger(subsystem: "com.example.app.tunnel", category: "provider")
final class PacketTunnelProvider: NEPacketTunnelProvider {
private var tunnelFileDescriptor: Int32 = -1
// 1. Called by the system when the VPN should connect
override func startTunnel(
options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void
) {
log.info("startTunnel called")
// Build the tunnel network settings
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "198.51.100.1")
settings.mtu = 1400
let ipv4 = NEIPv4Settings(addresses: ["10.8.0.2"], subnetMasks: ["255.255.255.0"])
ipv4.includedRoutes = [NEIPv4Route.default()]
settings.ipv4Settings = ipv4
let dns = NEDNSSettings(servers: ["1.1.1.1", "8.8.8.8"])
settings.dnsSettings = dns
// Apply settings, then signal success
setTunnelNetworkSettings(settings) { [weak self] error in
if let error {
completionHandler(error)
return
}
self?.startReadingPackets()
completionHandler(nil)
}
}
// 2. Read packets from the virtual tun interface and forward them
private func startReadingPackets() {
packetFlow.readPacketObjects { packets in
for packet in packets {
// Forward packet.data over your real transport here
_ = packet.data
}
self.startReadingPackets() // re-arm
}
}
// 3. Called when the VPN should disconnect
override func stopTunnel(
with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void
) {
log.info("stopTunnel: \(reason.rawValue)")
completionHandler()
}
// 4. Handle messages sent from the container app
override func handleAppMessage(
_ messageData: Data,
completionHandler: ((Data?) -> Void)?
) {
let reply = "ACK".data(using: .utf8)
completionHandler?(reply)
}
}
// ─── VPNManager.swift (Container app) ─────────────────────────────────────
import NetworkExtension
import SwiftUI
@MainActor
@Observable
final class VPNManager {
private(set) var status: NEVPNStatus = .invalid
private var manager: NETunnelProviderManager?
private let bundleID = "com.example.app.tunnel"
private let serverAddress = "vpn.example.com"
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(statusDidChange),
name: .NEVPNStatusDidChange,
object: nil
)
Task { await load() }
}
// Load (or create) preferences from the system keychain
func load() async {
do {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
manager = managers.first(where: {
($0.protocolConfiguration as? NETunnelProviderProtocol)?
.providerBundleIdentifier == bundleID
}) ?? NETunnelProviderManager()
status = manager?.connection.status ?? .invalid
} catch {
print("VPNManager load error:", error)
}
}
func connect() async throws {
guard let manager else { return }
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = bundleID
proto.serverAddress = serverAddress
manager.protocolConfiguration = proto
manager.localizedDescription = "My VPN"
manager.isEnabled = true
try await manager.saveToPreferences()
try await manager.loadFromPreferences()
try manager.connection.startVPNTunnel()
}
func disconnect() {
manager?.connection.stopVPNTunnel()
}
@objc private func statusDidChange() {
status = manager?.connection.status ?? .disconnected
}
}
// ─── VPNControlView.swift (Container app UI) ──────────────────────────────
import SwiftUI
import NetworkExtension
struct VPNControlView: View {
@State private var vpn = VPNManager()
var body: some View {
VStack(spacing: 24) {
statusBadge
Toggle(
vpn.status == .connected ? "Disconnect" : "Connect",
isOn: Binding(
get: { vpn.status == .connected || vpn.status == .connecting },
set: { on in
Task {
if on { try? await vpn.connect() }
else { vpn.disconnect() }
}
}
)
)
.toggleStyle(.switch)
.accessibilityLabel("VPN toggle")
.accessibilityHint(
vpn.status == .connected ? "Tap to disconnect" : "Tap to connect"
)
.padding(.horizontal)
}
.padding()
.navigationTitle("My VPN")
}
@ViewBuilder
private var statusBadge: some View {
Label(vpn.status.localizedDescription, systemImage: statusIcon)
.font(.headline)
.foregroundStyle(statusColor)
.padding(.vertical, 8)
.padding(.horizontal, 20)
.background(statusColor.opacity(0.12), in: Capsule())
}
private var statusIcon: String {
switch vpn.status {
case .connected: return "lock.fill"
case .connecting: return "arrow.trianglehead.clockwise"
case .disconnecting:return "arrow.trianglehead.counterclockwise"
default: return "lock.open"
}
}
private var statusColor: Color {
switch vpn.status {
case .connected: return .green
case .connecting, .disconnecting: return .orange
default: return .secondary
}
}
}
extension NEVPNStatus {
var localizedDescription: String {
switch self {
case .invalid: return "Not configured"
case .disconnected: return "Disconnected"
case .connecting: return "Connecting…"
case .connected: return "Connected"
case .reasserting: return "Reconnecting…"
case .disconnecting: return "Disconnecting…"
@unknown default: return "Unknown"
}
}
}
#Preview {
NavigationStack {
VPNControlView()
}
}
How it works
-
Two-process architecture.
PacketTunnelProviderruns in a sandboxed extension process managed by the OS. The container app communicates with it viaNETunnelProviderManagerand, for custom messages,NETunnelProviderSession.sendProviderMessage(_:). The OS boots and terminates the extension process independently of the app. -
setTunnelNetworkSettings(_:completionHandler:)instartTunneltells the system which IP addresses, routes, and DNS servers to configure on the virtual interface. Calling the completion handler withnilsignals that the tunnel is ready, which changes the connection status to.connected. -
packetFlow.readPacketObjectsis the async read loop on the virtual TUN interface. Each call delivers a batch ofNEPacketobjects; you re-arm after each batch by callingreadPacketObjectsagain. In production you'd forward the raw data over a real encrypted transport (e.g., WireGuard, a custom UDP socket). -
@Observable VPNManagerwrapsNETunnelProviderManager, keeping the SwiftUI layer thin. TheNEVPNStatusDidChangenotification fires on any connection-state change, and the@objc statusDidChange()observer bridges it back to the@MainActorstatus property that drives the UI. -
Toggle binding. Rather than binding a
Boolproperty directly, theBinding(get:set:)initializer maps theNEVPNStatusenum to a boolean so the system Toggle behaves correctly during the transient.connectingand.disconnectingstates.
Variants
Send a custom message from the app to the provider
// Container app
func sendMessage(_ payload: String) async throws -> String? {
guard
let session = manager?.connection as? NETunnelProviderSession,
let data = payload.data(using: .utf8)
else { return nil }
return try await withCheckedThrowingContinuation { continuation in
do {
try session.sendProviderMessage(data) { replyData in
let reply = replyData.flatMap { String(data: $0, encoding: .utf8) }
continuation.resume(returning: reply)
}
} catch {
continuation.resume(throwing: error)
}
}
}
// Provider — already handled by handleAppMessage above
// override func handleAppMessage(_ messageData: Data,
// completionHandler: ((Data?) -> Void)?) { ... }
DNS Proxy Provider instead of Packet Tunnel
If you only need to intercept DNS queries (e.g., for content filtering or split-horizon DNS), subclass NEDNSProxyProvider and set its NEDNSProxyProviderProtocol instead. The entitlement changes to com.apple.developer.networking.networkextension with the dns-proxy value, and you handle flows via handleNewFlow(_:) rather than packetFlow. This requires a significantly narrower entitlement scope and is easier to get approved by Apple for non-VPN use cases.
Common pitfalls
-
Missing entitlements crash silently. Both the container app and the extension target need the
Network Extensionscapability enabled in Xcode, and your provisioning profile must include the corresponding entitlement (e.g.,packet-tunnel-provider). A mismatch causessaveToPreferencesto fail with a crypticNEConfigurationErrorDomainerror. Always test on a real device — the Simulator does not support NetworkExtension at all. -
Re-arming
readPacketObjectscorrectly. CallingreadPacketObjectsonly once means your tunnel silently stops forwarding packets after the first batch. Always recurse at the end of your read handler. Conversely, calling it on a tight loop without yielding to handle the packets starves the extension's run loop. -
Memory pressure and background termination. The extension process runs with a very small memory budget. Caching large cryptographic state or buffering packets in-memory will get your process killed. Use
NEPacketdata transiently and write any persistent state to a shared App Group container. Also implementstopTunnelpromptly — lingering work after the completion handler is called is undefined behavior.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a Network Extension (Packet Tunnel Provider) in SwiftUI for iOS 17+. Use NetworkExtension, NEPacketTunnelProvider, NETunnelProviderManager, and NEVPNStatus. Add a container-app VPNManager (@Observable) that loads preferences, connects, disconnects, and observes NEVPNStatusDidChange notifications. Build a VPNControlView with a Toggle and an accessible status badge. Make it accessible (VoiceOver labels and hints for all interactive controls). Add a #Preview with realistic sample data and a NavigationStack wrapper.
Drop this prompt into Soarias during the Build phase to scaffold both targets — the tunnel extension and the SwiftUI manager UI — in one pass, then iterate with follow-up prompts for your real transport layer.
Related
FAQ
Does this work on iOS 16?
The NETunnelProviderManager async/await APIs (loadAllFromPreferences() as an async function, saveToPreferences() returning Void with Swift concurrency) require iOS 17+. On iOS 16 you must use the callback-based overloads. The core NEPacketTunnelProvider class itself has been available since iOS 9, so the extension binary is backward-compatible if you guard the async calls.
Do I need special Apple approval to ship a Network Extension?
Yes. Packet Tunnel Provider and most other Network Extension subtypes require a special entitlement that you must request from Apple via the developer portal before your provisioning profile will include it. Personal Team accounts cannot use these entitlements. Submit a request at developer.apple.com → Account → Additional Capabilities, explaining your use case. DNS Proxy and App Proxy have similar requirements. Plan for a review turnaround of several business days.
What is the UIKit / pre-SwiftUI equivalent?
The NetworkExtension framework is UI-agnostic — it predates SwiftUI entirely. UIKit apps use the exact same NETunnelProviderManager APIs and observe the same NEVPNStatusDidChange notification via NotificationCenter. The only difference is that instead of an @Observable class driving a SwiftUI view, you update UILabels and UISwitch controls from the notification handler. The provider extension code is identical regardless of the UI framework.
Last reviewed: 2026-05-12 by the Soarias team.