Skip to main content

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")
ModeBehaviour
.autoThe 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.
info

.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:

  1. Saves the selected location to shared state (App Group container)
  2. Starts the Network Extension process
  3. The extension reads the location and authenticates WireGuard endpoints via the SDK
  4. 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

ScenarioKapeVPNError 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
info

connectionError is nil while the tunnel is connected or idle. It only carries a value after a failed connection attempt.