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, the selected VPN protocol, and authenticates endpoints via the SDK
  4. 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​

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.