Embedding MEHS in a Flutter app (Android, Windows, Linux, iOS)
The MEHS client library provides the complete client: authorization, the TLS/QUIC data path, a local SOCKS5/HTTP proxy or system-wide TUN, and automatic reconnect with backoff. Android, Windows, and Linux apps use Dart FFI. iOS embeds the static library in a Swift NEPacketTunnelProvider and uses the packet-flow API described in iOS integration.
For host-owned sleep, wake, and network-change handling, see the platform lifecycle sequence. The mehs_runtime_* wrapper documented below starts the client supervisor and does not expose those lifecycle callbacks; the mehs_on_* callbacks apply to the lower-level MehsClient API.
| Platform | Library in the release package | Notes |
|---|---|---|
| Android | jniLibs/{arm64-v8a,armeabi-v7a,x86_64}/libmehs_client_ffi.so | TUN via VpnService file descriptor. |
| Windows | mehs_client_ffi.dll and wintun.dll (matching architecture: x64, x86, or arm64) | TUN needs the app to run elevated. |
| Linux | libmehs_client_ffi.so | TUN needs root or CAP_NET_ADMIN. |
| iOS | MEHS.xcframework in the Packet Tunnel extension | Requires the Swift NEPacketTunnelProvider bridge; see iOS integration. |
Download the appropriate target package from MEHS releases. Wintun is architecture specific: ship wintun.dll from the same package as the matching mehs_client_ffi.dll. Windows hosts also need the Microsoft Visual C++ v14 Redistributable for the library's architecture. Linux release packages target Debian 13 and require the system libstdc++6 runtime.
1. The API (6 functions you need)
| Function | Purpose |
|---|---|
mehs_runtime_start(args_json, secret, &handle, err, err_cap, &err_len) | Parse arguments and start the client on its own thread. Returns immediately. MEHS_INVALID_CONFIG (9) plus a message indicates bad arguments. |
mehs_runtime_status(handle, buf, cap, &len) | Fills buf with a JSON status. MEHS_BUFFER_TOO_SMALL (3) reports the needed len. |
mehs_runtime_stop(handle) | Stops the client, removes TUN routes, joins the thread, and frees the handle. Call exactly once. |
mehs_config_validate_json(...) | Optional: validate a session-config JSON before saving it in your UI. |
mehs_protocol_version() | Wire-protocol version; not the release version. |
mehs_core_version() | NUL-terminated client release string, for example MEHS Core 0.9.2.2-beta Client. Available on Android, Windows, Linux, and iOS; the returned pointer remains valid for the process lifetime. |
Status JSON never contains secrets:
{
"state":"running","phase":"Established","transport":"Tcp","epoch":"4802751011896773293",
"interface":null,"socks":"127.0.0.1:21080","http":null,"tun":false,"sessions":1,
"error":null,"log":["session established (Tcp)"]
}
state is one of starting, running, reconnecting, stopped, or failed. failed is terminal (bad credentials, certificate, unreachable server on first connect, TUN setup failure, or exhausted reconnection budget). reconnecting retries automatically with delays of 1, 2, 4, 8, 16, then five retries at 30 seconds: 181 seconds of scheduled waiting, excluding connection-attempt duration. Set "inf_reconnect": true in the client session JSON loaded through --session-config to remove the attempt limit; it defaults to false and does not make initial connection or non-retryable setup failures retryable. A session lasting at least 60 seconds resets that budget. epoch is a string because it does not fit a JavaScript/Dart-web integer. log keeps the last 32 messages.
args_json is a JSON array of CLI arguments. See the CLI guide for invocation examples. Pass the secret through the secret parameter, not in the array.
For Dart FFI, bind mehs_core_version as Pointer<Utf8> Function() and convert the result with toDartString(). The string follows the same release-version source as mehs version.
2. Dart wrapper
Add ffi: ^2.1.0 to your app's dependencies.
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
typedef _StartC = Int32 Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Pointer<Void>>,
Pointer<Uint8>, IntPtr, Pointer<IntPtr>);
typedef _StartD = int Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Pointer<Void>>,
Pointer<Uint8>, int, Pointer<IntPtr>);
typedef _StatusC = Int32 Function(Pointer<Void>, Pointer<Uint8>, IntPtr, Pointer<IntPtr>);
typedef _StatusD = int Function(Pointer<Void>, Pointer<Uint8>, int, Pointer<IntPtr>);
typedef _StopC = Int32 Function(Pointer<Void>);
typedef _StopD = int Function(Pointer<Void>);
class Mehs {
Mehs._(this._start, this._status, this._stop);
final _StartD _start;
final _StatusD _status;
final _StopD _stop;
Pointer<Void> _handle = nullptr;
static Mehs load() {
final lib = DynamicLibrary.open(
Platform.isWindows ? 'mehs_client_ffi.dll'
: 'libmehs_client_ffi.so'); // Android and Linux
return Mehs._(
lib.lookupFunction<_StartC, _StartD>('mehs_runtime_start'),
lib.lookupFunction<_StatusC, _StatusD>('mehs_runtime_status'),
lib.lookupFunction<_StopC, _StopD>('mehs_runtime_stop'),
);
}
/// Throws [StateError] with a secret-free message if the arguments are invalid.
void start(List<String> args, String secret) {
final a = jsonEncode(args).toNativeUtf8();
final s = secret.toNativeUtf8();
final out = calloc<Pointer<Void>>();
final err = calloc<Uint8>(512);
final errLen = calloc<IntPtr>();
try {
final rc = _start(a, s, out, err, 512, errLen);
if (rc != 0) {
throw StateError('MEHS start failed ($rc): '
'${utf8.decode(err.asTypedList(errLen.value.clamp(0, 512)))}');
}
_handle = out.value;
} finally {
calloc.free(a); calloc.free(s); calloc.free(out);
calloc.free(err); calloc.free(errLen);
}
}
Map<String, dynamic> status() {
var cap = 4096;
while (true) {
final buf = calloc<Uint8>(cap);
final len = calloc<IntPtr>();
try {
final rc = _status(_handle, buf, cap, len);
if (rc == 3) { cap = len.value; continue; } // buffer too small
if (rc != 0) throw StateError('MEHS status failed ($rc)');
return jsonDecode(utf8.decode(buf.asTypedList(len.value)));
} finally { calloc.free(buf); calloc.free(len); }
}
}
void stop() {
if (_handle != nullptr) { _stop(_handle); _handle = nullptr; }
}
}
Usage (proxy mode, which works on Android, Windows, and Linux without elevation):
final mehs = Mehs.load();
mehs.start([
'--control', 'vpn.example.com:8443', '--client-id', 'my-device',
'--ca-file', caPemPath, '--transport', 'tcp',
'--socks-bind', '127.0.0.1:1080', '--http-bind', '127.0.0.1:8080',
'--udp-associate', '--remote-dns',
], secret);
Timer.periodic(const Duration(seconds: 1), (_) => print(mehs.status()['state']));
// later, e.g. on dispose / app exit:
mehs.stop();
Call stop() before the process exits; in TUN mode this removes the routes (if the host is killed instead, the next start() removes stale routes). Call start/status/stop from one isolate (the handle is a plain pointer).
3. Android
- Copy
jniLibs/from the Android release package intoandroid/app/src/main/jniLibs/. Flutter bundles the.sofiles per ABI automatically;DynamicLibrary.open('libmehs_client_ffi.so')then resolves.Include both
libmehs_client_ffi.soand the matchinglibc++_shared.sofor each ABI; the fingerprint TLS backend requires the shared C++ runtime. PreserveNOTICE.android-ndkwith the app's third-party notices. If another plugin already bundleslibc++_shared.so, resolve the duplicate by using a compatible single NDK runtime rather than dropping the dependency. - Declare the
INTERNETpermission inAndroidManifest.xml. For TUN, also declare aVpnServicesubclass withandroid:permission="android.permission.BIND_VPN_SERVICE". - Proxy mode needs nothing else: start with
--socks-bind 127.0.0.1:1080and point your WebView/HTTP client at it. (Tested on an arm64 device.) - TUN mode uses a platform channel to a Kotlin
VpnService. The service owns VPN consent, tunnel settings, lifecycle, and routing; MEHS owns the detached TUN descriptor after it is passed to--tun-fd.class MehsVpnService : VpnService() { fun establishFd(): Int { val builder = Builder() .setSession("MEHS") .setMtu(1400) .addAddress("198.18.0.1", 16) .addAddress("fd77:6d65:6873::1", 64) .addRoute("0.0.0.0", 0) .addRoute("::", 0) .addDnsServer("1.1.1.1") // The current FFI runtime has no per-socket VpnService.protect hook. // Excluding the package keeps MEHS carrier sockets off its own VPN. .addDisallowedApplication(packageName) val pfd = builder.establish() ?: error("VPN permission not granted") return pfd.detachFd() // ownership passes to MEHS } }Declare the service in the app manifest with
android.permission.BIND_VPN_SERVICEand theandroid.net.VpnServiceintent action:<service android:name=".MehsVpnService" android:permission="android.permission.BIND_VPN_SERVICE" android:exported="true"> <intent-filter> <action android:name="android.net.VpnService" /> </intent-filter> </service>Request consent with
VpnService.prepare(context)before establishing the interface. Start the service in the foreground with its persistent notification while the VPN is active; comply with the target Android version's foreground-service requirements. Add addresses, routes, and DNS that match the product's intended IPv4/IPv6 policy. The example is a dual-stack full tunnel. If you intentionally omit IPv6 routes, IPv6 traffic uses the device's normal route rather than the VPN.addDisallowedApplication(packageName)excludes all traffic from the app's UID, not only MEHS carrier sockets. This avoids a carrier routing loop with the current runtime; it also means the app's own WebView and other app sockets bypass the VPN. Android does not provide a mechanism for the Rust runtime to callVpnService.protect()on its carrier sockets through this FFI.After
detachFd(), do not close or reuse the raw descriptor. Pass it once to Dart:mehs.start([ '--control', 'vpn.example.com:8443', '--client-id', 'my-device', '--mode', 'tun', '--tun-fd', '$fd', '--tun-mtu', '1400', '--ca-file', caPemPath, ], secret);--tun-fdimplies--tun-no-routes: Android'sVpnService.Builderowns routing.mehs_runtime_startreturns after spawning its worker; it does not synchronously validate the descriptor. Poll status and treatstate == "failed"as a startup failure, showing the secret-freeerrorand stopping the handle. The runtime rejects a closed/invalid fd as a TUN startup error; on normal stop it closes the adopted descriptor. - Override
VpnService.onRevoke(): stop the MEHS runtime, clear the stored handle, and stop the service. Revocation invalidates the current tunnel; do not reconnect using the old descriptor. Ask for consent again, establish a new interface, and start a new runtime with its fresh fd. If the fd fails during packet I/O, the runtime reports a terminalfailedstate; after stopping it, recreate the VPN interface and runtime rather than retrying with the revoked fd. - Handle network changes (Wi-Fi ↔ mobile) with
ConnectivityManager.NetworkCallback. Re-read the active network and update/re-establish the VPN according to the service policy. MEHS reconnects its carrier after transport loss, but aVpnServicefd is tied to the established interface and is not automatically replaced by carrier reconnection.
4. Windows
- Place
mehs_client_ffi.dll(and, for TUN,wintun.dll) next to the Flutter runner executable. Include the files from the matching Windows release package in your app's runtime distribution. - Use the package matching the app architecture (Flutter targets x64 and arm64; x86 is a separate package).
- Proxy mode needs no elevation. TUN mode needs the app to run as Administrator (add a
requireAdministratormanifest) and must not run while another VPN with its own TUN (for example Happ/xray) is active on the same machine. - Always call
stop()on exit so routes are removed. If the app is killed, remove leftovers withGet-NetRoute -InterfaceAlias mehs0 | Remove-NetRoute -Confirm:$false(the adapter vanishes with the process).
5. Linux
- Bundle
libmehs_client_ffi.sofrom the Linux release package in the Flutter bundle'slib/directory.DynamicLibrary.open('libmehs_client_ffi.so')finds it through the bundle RPATH. - Proxy mode needs nothing more.
- TUN mode needs
CAP_NET_ADMIN: run the app as root, or grantsudo setcap cap_net_admin+ep ./your_app(routes are installed with theipcommand, soiproute2must be present). Prefer shipping a small privileged helper over running the whole UI as root.
6. Robustness checklist
- Persist
client_idandsecretin the platform secure store (Keystore, DPAPI/Credential Manager, libsecret). Never log them; MEHS itself never prints them. - Show
state; surfaceerrorandlogfor support. Do not poll faster than once per second. - A
failedstate is final: show the error, fix configuration, callstop(), and start again. - Ship the server CA PEM with the app and pass it with
--ca-file, rather than trusting the system store, to pin your server. - On Android and Windows verify 24-hour behaviour on your own devices: MEHS keeps TCP/QUIC keepalives and reconnects with backoff from 1 second to 30 seconds, but OS power management (Doze, sleep) may suspend the process. Recovery on wake is subject to the retry budget above; after terminal
failed, the host must explicitly start a new runtime. - ABI/compatibility: the API above is stable within 0.9.x. The wire protocol has no cross-release guarantee, so keep client and server on the same release.