Connecting
VPN Profile
Before connecting, a VPN profile must be installed. This shows a system dialog asking the user to allow VPN configurations:
let vpn = KapeVPNManager(handle: handle)
try await vpn.installProfile()
vpn.isProfileInstalled // true after approval
The profile persists across app launches. You only need to install it once.
Connection Modes
KapeVPNManager supports two connection modes via KapeConnectionMode:
// Automatic — SDK picks the best available server
try await vpn.connect(mode: .auto)
// Manual — connect to a specific server
try await vpn.connect(mode: .manual(locationId: "us-east-1"))
// Direct shorthand for manual
try await vpn.connect(to: "us-east-1")
| Mode | Behaviour |
|---|---|
.auto | The SDK selects the best available location from cached SmartLocation data (highest-rated entry). If no SmartLocation data is cached yet, the first location in the full server list is used. No network request is made. |
.manual(locationId:) | Connects to the specified location. The locationId comes from KapeServerLocation.id. |
.auto reads from the SmartLocation cache — it never triggers a new measurement. Call fetchSmartLocations()
beforehand to ensure the cache is populated. If the cache is empty, the first server in the unordered server list is
used as a fallback.
What happens on connect
All modes follow the same internal flow:
- Saves the selected location to shared state (App Group container)
- Starts the Network Extension process
- The extension reads the location and authenticates WireGuard endpoints via the SDK
- Establishes the WireGuard tunnel
If already connected, calling any connect variant with a different location will switch servers without disconnecting first.
Disconnecting
await vpn.disconnect()
Connection Status
Observe the connection status via Combine:
vpn.$connectionStatus
.sink { status in
switch status {
case .disconnected: // Not connected
case .connecting: // Establishing tunnel
case .connected: // Tunnel active
case .disconnecting: // Tearing down
}
}
.store(in: &cancellables)
Connection Errors
Tunnel start errors occur asynchronously inside the Network Extension process and are not propagated as thrown errors
from connect(to:). Instead, KapeVPNManager exposes a dedicated Combine publisher:
vpn.$connectionError
.compactMap { $0 }
.sink { error in
print("Tunnel failed: \(error.localizedDescription)")
}
.store(in: &cancellables)
connectionError is of type KapeVPNError? and is set when a .connecting → .disconnected status transition is
observed and the extension has recorded a failure reason. It is automatically cleared on the next connect(to:) or
connect(mode:) call.
Why errors are not thrown from connect
connect(to:) and connect(mode:) both call startVPNTunnel() on the NETunnelProviderSession. This call returns
immediately — the tunnel runs in a separate, sandboxed process. Any error that occurs during WireGuard setup (no
endpoints, authentication failure, handshake timeout) is thrown inside the extension and terminates the extension
process; it never reaches the calling app.
KapePacketTunnelProvider writes the error description to a shared JSON file in the App Group container before
rethrowing. KapeVPNManager detects the .connecting → .disconnected status transition (delivered via Darwin
notification) and reads that file to populate connectionError.
Subscribing in SwiftUI
vpn.$connectionError
.receive(on: DispatchQueue.main)
.compactMap { $0?.localizedDescription }
.sink { [weak self] description in
self?.errorMessage = description
}
.store(in: &cancellables)
Common error values
| Scenario | KapeVPNError case |
|---|---|
| No endpoints available for the location | .noEndpointsAvailable |
| WireGuard authentication with the server failed | .authenticationFailed |
| WireGuard handshake timed out | .tunnelStartFailed |
| Another VPN profile is active | .tunnelStartFailed |
connectionError is nil while the tunnel is connected or idle. It only carries a value after a failed connection
attempt.