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, the selected VPN protocol, and authenticates endpoints via the SDK
- Establishes the tunnel (WireGuard, Lightway, or OpenVPN, depending on the selected protocol)
To change the protocol, see VPN Protocol.
If already connected, calling any connect variant with a different location will switch servers without disconnecting first.
Disconnecting
await vpn.disconnect()
Signing Out
await vpn.signOut()
Goes further than disconnect(): it also removes the installed VPN configuration and resets every
persisted VPN setting — selected location, Dedicated IP selection, active subscription, proxies
(and their keychain secrets), Connect on Demand rules and pause state, plus protocol, transport,
MTU, cipher, SNI, kill switch, split tunnel, custom DNS and Advanced Protection. The next account
starts from SDK defaults.
Call it alongside KapeAuthClient.logout(), which cannot reach the tunnel itself — see
Authentication. It is non-throwing, idempotent and safe to call with
nothing connected.
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.