A Flutter plugin to set up and control a VPN connection over a WireGuard tunnel.
It embeds WireGuard's own implementation for each OS —
WireGuardKit on Apple platforms, com.wireguard.android:tunnel on Android, and the official
tunnel.dll service on Windows — so a host app needs no additional VPN dependency.
| Android | iOS | macOS | Windows | Linux | |
|---|---|---|---|---|---|
| Minimum version | API 24 | 15.0 | 12.0 | 10 | — |
| Engine | com.wireguard.android:tunnel |
WireGuardKit | WireGuardKit | tunnel.dll + wireguard.dll |
— |
| Connect / disconnect | ✅ | ✅ | ✅ | ✅ | ❌ |
| Status stream | ✅ | ✅ | ✅ | ✅ | ❌ |
| Key generation | ✅ | ✅ | ✅ | ✅ | ❌ |
| Tunnel configuration check / removal | ✅ | ✅ | ✅ | ✅ | ❌ |
| Tunnel statistics | ✅ | ✅ | ✅ | ✅ | ❌ |
| Notification permission helpers | ✅ | n/a | n/a | n/a | ❌ |
Linux ships a stub plugin that answers only getPlatformVersion; every other method throws
MissingPluginException.
The plugin is not published to pub.dev. Depend on a tagged ref:
dependencies:
wireguard_dart:
git:
url: https://github.com/mysteriumnetwork/wireguard_dart.git
ref: 0.9.13The Flutter SDK version is pinned exactly (environment: flutter: 3.44.7), so a consuming app
must build on that version. It is also recorded in .fvmrc for FVM.
final wireguard = WireguardDart();
// Once per process, before anything else.
await wireguard.nativeInit();
// Generate the client keypair (persist the private key yourself).
final keys = await wireguard.generateKeyPair();
// Create/load the platform tunnel. `win32ServiceName` is Windows-only.
await wireguard.setupTunnel(
bundleId: 'com.example.app.tun', // the packet-tunnel extension's bundle id
tunnelName: 'Example VPN',
win32ServiceName: 'ExampleVPNTunnel',
);
// Watch the tunnel state.
wireguard.statusStream().listen((status) => print('tunnel: $status'));
// Connect with a wg-quick style configuration.
await wireguard.connect(cfg: wgQuickConfig);
// Live counters (see "Tunnel statistics" below).
final stats = await wireguard.getTunnelStatistics();
print('rx=${stats?.totalDownload} tx=${stats?.totalUpload}');
await wireguard.disconnect();The library manifest declares android.permission.POST_NOTIFICATIONS, needed for the foreground
service notification on Android 13+ (API 33+). connect() does not hard-fail without it, but
the tunnel notification will be invisible. Use the helpers to prompt:
if (await wireguard.checkNotificationPermission() != NotificationPermission.granted) {
final result = await wireguard.requestNotificationPermission();
if (result == NotificationPermission.permanentlyDenied) {
await wireguard.openAppNotificationSettings();
}
}The host app must ship a Packet Tunnel Provider extension target whose principal class
subclasses WireGuardTunnelProvider (from the WireGuardKit pod) and forwards the base
implementations:
class PacketTunnelProvider: WireGuardTunnelProvider {
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
super.handleAppMessage(messageData, completionHandler: completionHandler)
}
}Forwarding handleAppMessage is required for tunnel statistics — that is the channel the
plugin queries. Declare the pod on the app target and let the extension target inherit search
paths from it:
target 'Runner' do
use_frameworks!
use_modular_headers!
pod 'WireGuardKit', :podspec => "https://raw.githubusercontent.com/mysteriumnetwork/wireguard-apple/0.5/WireGuardKit.podspec"
target 'tun' do
inherit! :search_paths
end
endThe extension target needs the Network Extensions capability with packet-tunnel-provider in its
entitlements. The bundleId passed to setupTunnel is written to
NETunnelProviderProtocol.providerBundleIdentifier, so it must be the extension's bundle
identifier — it is also the key the plugin matches on when looking up an existing tunnel.
The plugin starts the WireGuard tunnel as a Windows service, so the host process must be
elevated — connect() calls CreateService. wireguard_svc.exe, tunnel.dll and wireguard.dll
are bundled and copied next to the app executable at build time. nativeInit() also stops and
disables the RemoteAccess service, which conflicts with WireGuard routing.
getTunnelStatistics() returns TunnelStatistics? — totalDownload (rx), totalUpload (tx) and
latestHandshake in epoch milliseconds. Counters are cumulative for the session; latestHandshake
is 0 until the first handshake completes.
How each platform sources them:
| Platform | Mechanism | Wire format |
|---|---|---|
| Android | Backend.getStatistics(tunnel) |
JSON |
| iOS / macOS | sendProviderMessage to the extension, answered by wgGetConfig |
UAPI text |
| Windows | The tunnel service's UAPI named pipe (get=1) |
UAPI text |
UAPI text is parsed in Dart by TunnelStatistics.fromUapi, shared by both platforms that use it,
including the seconds + nanoseconds handshake fields. Notes worth knowing:
- Contract. Platform-level failures — "not connected", an unreadable pipe, a truncated
response — return
null. A platform with no implementation at all throwsMissingPluginException, so a poller can stop permanently instead of retrying forever. - Cost. On Apple platforms each call is an IPC round-trip that wakes the network extension; on Windows it opens a named pipe. Poll no faster than you need, and stop when disconnected.
- Windows elevation. The UAPI pipe is protected by an Administrators-only DACL, satisfied by
the same elevation
connect()already requires.
| Method | Purpose |
|---|---|
nativeInit() |
Per-process native setup; on Windows also disables the conflicting RemoteAccess service |
generateKeyPair() |
Returns a KeyPair (publicKey, privateKey) |
setupTunnel({bundleId, tunnelName, win32ServiceName}) |
Creates or loads the platform tunnel configuration |
connect({cfg}) |
Starts the tunnel from a wg-quick style configuration |
disconnect() |
Stops the tunnel |
status() |
One-shot ConnectionStatus |
statusStream() |
Distinct ConnectionStatus updates |
checkTunnelConfiguration({bundleId, tunnelName}) |
Whether a tunnel configuration already exists |
removeTunnelConfiguration({bundleId, tunnelName}) |
Deletes the tunnel configuration |
getTunnelStatistics() |
Live byte counters and last handshake |
checkNotificationPermission() |
Android notification permission state |
requestNotificationPermission() |
Prompts for the Android notification permission |
openAppNotificationSettings() |
Opens the system notification settings |
ConnectionStatus is one of connecting, connected, disconnecting, disconnected, unknown
(the fallback for anything unrecognised).
All commands go through FVM so they use the pinned SDK. The Makefile wraps
the common ones:
make init # flutter pub get
make generate # build_runner (mockito mocks) + dart format
make analyze # flutter analyze
make test # flutter test
make check # format + analyze + test, as CI runs itThe Dart test suite covers the platform-interface contract, the method-channel dispatch (including
which errors are swallowed and which propagate), the UAPI parser, and the models. Native code has
no test target, so Swift and C++ changes are verified by building example/:
cd example && flutter build ios --simulator --debug # compiles the darwin plugin
cd example && flutter build windows --debug # compiles the Windows pluginCI runs pub get, codegen and the test suite on every PR, installing the SDK from .fvmrc so it
matches the exact pin in pubspec.yaml.
- Open a PR with the proposed changes:
- Add
[major]to the title for breaking changes - Add
[minor]for new features - Otherwise it is a patch release — add nothing
- Add
- Once checks pass and the PR is approved, merge it
- Update
CHANGELOG.mdand tag the release manually (semantic-release from the title is disabled)