diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..e878465
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,36 @@
+group 'io.svrnty.tsnet_flutter'
+version '1.0'
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.1.0'
+ }
+}
+
+rootProject.allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+apply plugin: 'com.android.library'
+
+android {
+ namespace 'io.svrnty.tsnet_flutter'
+ compileSdk 34
+
+ defaultConfig {
+ minSdk 21
+ }
+
+ sourceSets {
+ main {
+ jniLibs.srcDirs = ['src/main/jniLibs']
+ }
+ }
+}
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6d08bc2
--- /dev/null
+++ b/android/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/android/src/main/jniLibs/arm64-v8a/libtailscale.so b/android/src/main/jniLibs/arm64-v8a/libtailscale.so
new file mode 100644
index 0000000..0e00e3d
Binary files /dev/null and b/android/src/main/jniLibs/arm64-v8a/libtailscale.so differ
diff --git a/android/src/main/jniLibs/x86_64/libtailscale.so b/android/src/main/jniLibs/x86_64/libtailscale.so
new file mode 100644
index 0000000..8ab36bf
Binary files /dev/null and b/android/src/main/jniLibs/x86_64/libtailscale.so differ
diff --git a/build_go.sh b/build_go.sh
index 02c41af..608290e 100755
--- a/build_go.sh
+++ b/build_go.sh
@@ -187,12 +187,85 @@ build_macos() {
du -sh "$OUTPUT"
}
+# ============================================================
+# Android (c-shared → .so in jniLibs)
+# ============================================================
+build_android() {
+ local NDK_HOME="${ANDROID_NDK_HOME:-$HOME/Library/Android/sdk/ndk/$(ls $HOME/Library/Android/sdk/ndk/ 2>/dev/null | sort -V | tail -1)}"
+
+ if [ ! -d "$NDK_HOME" ]; then
+ echo "Error: Android NDK not found. Set ANDROID_NDK_HOME."
+ exit 1
+ fi
+
+ local TOOLCHAIN="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64"
+ local OUTPUT_DIR="$SCRIPT_DIR/android/src/main/jniLibs"
+ mkdir -p "$OUTPUT_DIR/arm64-v8a" "$OUTPUT_DIR/x86_64"
+
+ # Android needs the "android" build tag for proper platform detection
+ # (e.g., netmon_polling.go instead of netmon_linux.go which requires CAP_NET_ADMIN)
+ local ANDROID_TAGS="android,$OMIT_TAGS"
+
+ # Android arm64
+ echo "==> [Android] Building for arm64-v8a..."
+ CGO_ENABLED=1 GOOS=android GOARCH=arm64 \
+ CC="$TOOLCHAIN/bin/aarch64-linux-android21-clang" \
+ go build -buildmode=c-shared -tags "$ANDROID_TAGS" -ldflags="-s -w" \
+ -o "$OUTPUT_DIR/arm64-v8a/libtailscale.so" .
+
+ # Android x86_64 (emulator)
+ echo "==> [Android] Building for x86_64..."
+ CGO_ENABLED=1 GOOS=android GOARCH=amd64 \
+ CC="$TOOLCHAIN/bin/x86_64-linux-android21-clang" \
+ go build -buildmode=c-shared -tags "$ANDROID_TAGS" -ldflags="-s -w" \
+ -o "$OUTPUT_DIR/x86_64/libtailscale.so" .
+
+ # Clean up generated headers (not needed for .so)
+ rm -f "$OUTPUT_DIR"/*/libtailscale.h
+
+ echo "==> [Android] Done:"
+ ls -lh "$OUTPUT_DIR/arm64-v8a/libtailscale.so"
+ ls -lh "$OUTPUT_DIR/x86_64/libtailscale.so"
+}
+
+# ============================================================
+# Linux (c-shared → .so)
+# ============================================================
+build_linux() {
+ local OUTPUT_DIR="$SCRIPT_DIR/linux"
+
+ # Linux amd64 — requires a Linux cross-compiler or building on Linux.
+ # On macOS, use Docker: docker run --rm -v $PWD:/src -w /src golang:1.23 ./build_go.sh linux
+ echo "==> [Linux] Building for amd64..."
+ if [ "$(uname)" = "Linux" ]; then
+ CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
+ go build -buildmode=c-shared -tags "$OMIT_TAGS" -ldflags="-s -w" \
+ -o "$OUTPUT_DIR/libtailscale.so" .
+ else
+ echo " Cross-compiling Linux from $(uname) via Docker..."
+ docker run --rm --platform linux/amd64 -v "$SCRIPT_DIR:/src" -w /src/ios/Go \
+ golang:latest bash -c "
+ CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
+ go build -buildmode=c-shared -tags '$OMIT_TAGS' -ldflags='-s -w' \
+ -o /src/linux/libtailscale.so .
+ "
+ fi
+
+ rm -f "$OUTPUT_DIR/libtailscale.h"
+
+ echo "==> [Linux] Done:"
+ ls -lh "$OUTPUT_DIR/libtailscale.so"
+}
+
# ============================================================
# Main
# ============================================================
case "$PLATFORM" in
- ios) build_ios ;;
- macos) build_macos ;;
- all) build_ios; build_macos ;;
- *) echo "Usage: $0 [ios|macos|all]"; exit 1 ;;
+ ios) build_ios ;;
+ macos) build_macos ;;
+ android) build_android ;;
+ linux) build_linux ;;
+ all) build_ios; build_macos; build_android; build_linux ;;
+ apple) build_ios; build_macos ;;
+ *) echo "Usage: $0 [ios|macos|android|linux|apple|all]"; exit 1 ;;
esac
diff --git a/ios/Go/android_interfaces.go b/ios/Go/android_interfaces.go
new file mode 100644
index 0000000..dd856b1
--- /dev/null
+++ b/ios/Go/android_interfaces.go
@@ -0,0 +1,39 @@
+//go:build android
+
+package main
+
+import (
+ "net"
+
+ "tailscale.com/net/netmon"
+)
+
+func init() {
+ // Register an alternate interface getter for Android.
+ // Go's net.Interfaces() uses netlink which requires CAP_NET_ADMIN,
+ // and /sys/class/net is restricted on modern Android.
+ // Provide a minimal loopback interface to satisfy tsnet's startup.
+ // tsnet uses userspace netstack and doesn't need real interface data.
+ netmon.RegisterInterfaceGetter(func() ([]netmon.Interface, error) {
+ return []netmon.Interface{
+ {
+ Interface: &net.Interface{
+ Index: 1,
+ MTU: 65536,
+ Name: "lo",
+ HardwareAddr: nil,
+ Flags: net.FlagUp | net.FlagLoopback | net.FlagRunning,
+ },
+ },
+ {
+ Interface: &net.Interface{
+ Index: 2,
+ MTU: 1500,
+ Name: "wlan0",
+ HardwareAddr: net.HardwareAddr{0x02, 0x00, 0x00, 0x00, 0x00, 0x01},
+ Flags: net.FlagUp | net.FlagRunning | net.FlagMulticast,
+ },
+ },
+ }, nil
+ })
+}
diff --git a/ios/Go/bridge.go b/ios/Go/bridge.go
index b6ad49b..3474a41 100644
--- a/ios/Go/bridge.go
+++ b/ios/Go/bridge.go
@@ -20,6 +20,8 @@ import (
"fmt"
"io"
"net"
+ "os"
+ "runtime"
"sync"
"unsafe"
@@ -50,6 +52,13 @@ func TailscaleStart(stateDir, authKey, hostname *C.char) *C.char {
return nil // already started — idempotent
}
+ // On Android, Go's net.Interfaces() uses netlink which requires
+ // CAP_NET_ADMIN. Set GODEBUG to use Go's pure DNS resolver and
+ // suppress network interface enumeration errors.
+ if runtime.GOOS == "android" {
+ os.Setenv("GODEBUG", "netdns=go")
+ }
+
s := &tsnet.Server{
Dir: C.GoString(stateDir),
Hostname: C.GoString(hostname),
diff --git a/lib/tsnet_ffi.dart b/lib/tsnet_ffi.dart
new file mode 100644
index 0000000..eac60fc
--- /dev/null
+++ b/lib/tsnet_ffi.dart
@@ -0,0 +1,125 @@
+import 'dart:ffi';
+import 'dart:io';
+
+import 'package:ffi/ffi.dart';
+import 'package:flutter/services.dart';
+
+// C function typedefs from bridge.go
+typedef _StartNative = Pointer Function(Pointer, Pointer, Pointer);
+typedef _StartProxyNative = Pointer Function(Pointer, Int32, Pointer);
+typedef _StartProxyDart = Pointer Function(Pointer, int, Pointer);
+typedef _SimpleNative = Pointer Function();
+typedef _OutParamNative = Pointer Function(Pointer>);
+typedef _FreeNative = Void Function(Pointer);
+typedef _FreeDart = void Function(Pointer);
+
+/// FFI-based implementation for Android and Linux.
+/// Loads the Go shared library and calls C functions directly.
+class TsnetFfi {
+ late final DynamicLibrary _lib;
+ late final _StartNative _start;
+ late final _StartProxyDart _startProxy;
+ late final _SimpleNative _stopProxy;
+ late final _SimpleNative _stop;
+ late final _OutParamNative _status;
+ late final _OutParamNative _tailscaleIP;
+ late final _FreeDart _free;
+
+ TsnetFfi() {
+ _lib = _loadLibrary();
+ _start = _lib.lookupFunction<_StartNative, _StartNative>('TailscaleStart');
+ _startProxy = _lib.lookupFunction<_StartProxyNative, _StartProxyDart>('TailscaleStartProxy');
+ _stopProxy = _lib.lookupFunction<_SimpleNative, _SimpleNative>('TailscaleStopProxy');
+ _stop = _lib.lookupFunction<_SimpleNative, _SimpleNative>('TailscaleStop');
+ _status = _lib.lookupFunction<_OutParamNative, _OutParamNative>('TailscaleStatus');
+ _tailscaleIP = _lib.lookupFunction<_OutParamNative, _OutParamNative>('TailscaleIP');
+ _free = _lib.lookupFunction<_FreeNative, _FreeDart>('TailscaleFreeString');
+ }
+
+ static DynamicLibrary _loadLibrary() {
+ if (Platform.isAndroid) {
+ return DynamicLibrary.open('libtailscale.so');
+ } else if (Platform.isLinux) {
+ // Look in the app's lib directory (where Flutter bundles native libs)
+ return DynamicLibrary.open('libtailscale.so');
+ }
+ throw UnsupportedError('FFI not supported on ${Platform.operatingSystem}');
+ }
+
+ String? _consumeString(Pointer ptr) {
+ if (ptr == nullptr) return null;
+ final str = ptr.toDartString();
+ _free(ptr);
+ return str;
+ }
+
+ void _checkError(Pointer errPtr, String code) {
+ final err = _consumeString(errPtr);
+ if (err != null) {
+ throw PlatformException(code: code, message: err);
+ }
+ }
+
+ Future start(String stateDir, String authKey, String hostname) async {
+ final dirC = stateDir.toNativeUtf8();
+ final keyC = authKey.toNativeUtf8();
+ final hostC = hostname.toNativeUtf8();
+ try {
+ final err = _start(dirC, keyC, hostC);
+ _checkError(err, 'START_FAILED');
+ } finally {
+ malloc.free(dirC);
+ malloc.free(keyC);
+ malloc.free(hostC);
+ }
+ }
+
+ Future startProxy(String remoteIP, int remotePort) async {
+ final ipC = remoteIP.toNativeUtf8();
+ final portOut = malloc();
+ try {
+ final err = _startProxy(ipC, remotePort, portOut);
+ _checkError(err, 'PROXY_FAILED');
+ return portOut.value;
+ } finally {
+ malloc.free(ipC);
+ malloc.free(portOut);
+ }
+ }
+
+ Future stopProxy() async {
+ final err = _stopProxy();
+ _checkError(err, 'STOP_PROXY_FAILED');
+ }
+
+ Future stop() async {
+ final err = _stop();
+ _checkError(err, 'STOP_FAILED');
+ }
+
+ Future status() async {
+ final errOut = malloc>();
+ try {
+ errOut.value = nullptr;
+ final json = _status(errOut);
+ final err = _consumeString(errOut.value);
+ if (err != null) throw PlatformException(code: 'STATUS_FAILED', message: err);
+ return _consumeString(json);
+ } finally {
+ malloc.free(errOut);
+ }
+ }
+
+ Future tailscaleIP() async {
+ final errOut = malloc>();
+ try {
+ errOut.value = nullptr;
+ final ip = _tailscaleIP(errOut);
+ final err = _consumeString(errOut.value);
+ if (err != null) throw PlatformException(code: 'IP_FAILED', message: err);
+ return _consumeString(ip) ?? '';
+ } finally {
+ malloc.free(errOut);
+ }
+ }
+}
diff --git a/lib/tsnet_flutter.dart b/lib/tsnet_flutter.dart
index ef243ab..67d07af 100644
--- a/lib/tsnet_flutter.dart
+++ b/lib/tsnet_flutter.dart
@@ -1,13 +1,16 @@
import 'dart:convert';
+import 'dart:io';
import 'package:flutter/services.dart';
+import 'package:tsnet_flutter/tsnet_ffi.dart';
/// Embedded Tailscale tsnet client for Flutter.
///
/// Provides a userspace WireGuard tunnel via Tailscale's tsnet library,
/// without requiring the user to install the Tailscale app or a VPN entitlement.
///
-/// Architecture: Go static library (tsnet) ↔ Swift C bridge ↔ MethodChannel ↔ Dart
+/// - iOS/macOS: Swift MethodChannel → C bridge → Go static library
+/// - Android/Linux: Dart FFI → Go shared library (.so)
///
/// Usage:
/// ```dart
@@ -19,52 +22,93 @@ import 'package:flutter/services.dart';
class TsnetFlutter {
static const _channel = MethodChannel('tsnet_flutter');
- /// Join the Tailnet with the given auth key.
- ///
- /// [authKey] is a Tailscale auth key (reusable, tag-scoped).
- /// [hostname] is the name this device will appear as in the Tailnet.
- ///
- /// The auth key is typically received from the heater during BLE provisioning.
- /// State is persisted to the app's documents directory so the device
- /// identity survives app restarts.
- Future start({
- required String authKey,
- String hostname = 'constellation-phone',
- }) async {
- await _channel.invokeMethod('start', {
- 'authKey': authKey,
- 'hostname': hostname,
- });
+ /// FFI backend for Android/Linux — initialized lazily.
+ TsnetFfi? _ffi;
+
+ bool get _useFfi => Platform.isAndroid || Platform.isLinux;
+
+ TsnetFfi get _ensureFfi => _ffi ??= TsnetFfi();
+
+ /// State directory for persisting Tailscale identity.
+ String? _stateDir;
+
+ Future _getStateDir() async {
+ if (_stateDir != null) return _stateDir!;
+
+ // Use a directory relative to the current working directory or temp
+ // For proper production use, pass a path from path_provider
+ late final Directory dir;
+ if (Platform.isAndroid) {
+ // Android: use the app's internal storage via the temp directory parent
+ final temp = Directory.systemTemp;
+ dir = Directory('${temp.parent.path}/tailscale_state');
+ } else if (Platform.isLinux) {
+ final home = Platform.environment['HOME'] ?? '/tmp';
+ dir = Directory('$home/.local/share/tailscale_state');
+ } else {
+ dir = Directory('${Directory.systemTemp.path}/tailscale_state');
+ }
+ await dir.create(recursive: true);
+ _stateDir = dir.path;
+ return _stateDir!;
}
- /// Create a localhost TCP proxy to a heater's Tailscale IP.
- ///
- /// Returns the local port number. Connect gRPC to `127.0.0.1:`.
- /// Traffic is forwarded through the Tailscale WireGuard tunnel to
- /// [heaterTailscaleIP]:[port] on the heater.
- ///
- /// Uses userspace netstack — no VPN entitlement needed on iOS.
- Future startProxy(String heaterTailscaleIP, {int port = 5050}) async {
- final localPort = await _channel.invokeMethod('startProxy', {
- 'ip': heaterTailscaleIP,
- 'port': port,
- });
- return localPort!;
+ /// Join the Tailnet with the given auth key.
+ Future start({
+ required String authKey,
+ String hostname = 'tsnet-flutter',
+ }) async {
+ if (_useFfi) {
+ final dir = await _getStateDir();
+ await _ensureFfi.start(dir, authKey, hostname);
+ } else {
+ await _channel.invokeMethod('start', {
+ 'authKey': authKey,
+ 'hostname': hostname,
+ });
+ }
+ }
+
+ /// Create a localhost TCP proxy to a remote Tailscale IP.
+ /// Returns the local port number.
+ Future startProxy(String remoteIP, {int port = 5050}) async {
+ if (_useFfi) {
+ return await _ensureFfi.startProxy(remoteIP, port);
+ } else {
+ final localPort = await _channel.invokeMethod('startProxy', {
+ 'ip': remoteIP,
+ 'port': port,
+ });
+ return localPort!;
+ }
}
/// Stop the local TCP proxy.
Future stopProxy() async {
- await _channel.invokeMethod('stopProxy');
+ if (_useFfi) {
+ await _ensureFfi.stopProxy();
+ } else {
+ await _channel.invokeMethod('stopProxy');
+ }
}
/// Disconnect from the Tailnet and clean up.
Future stop() async {
- await _channel.invokeMethod('stop');
+ if (_useFfi) {
+ await _ensureFfi.stop();
+ } else {
+ await _channel.invokeMethod('stop');
+ }
}
/// Get the current Tailscale connection status.
Future status() async {
- final json = await _channel.invokeMethod('status');
+ String? json;
+ if (_useFfi) {
+ json = await _ensureFfi.status();
+ } else {
+ json = await _channel.invokeMethod('status');
+ }
if (json == null) {
return TailscaleStatus(state: TailscaleState.stopped);
}
@@ -73,8 +117,12 @@ class TsnetFlutter {
/// Get this device's Tailscale IPv4 address (100.x.x.x).
Future tailscaleIP() async {
- final ip = await _channel.invokeMethod('tailscaleIP');
- return ip!;
+ if (_useFfi) {
+ return await _ensureFfi.tailscaleIP();
+ } else {
+ final ip = await _channel.invokeMethod('tailscaleIP');
+ return ip!;
+ }
}
}
diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt
new file mode 100644
index 0000000..2cdb758
--- /dev/null
+++ b/linux/CMakeLists.txt
@@ -0,0 +1,8 @@
+cmake_minimum_required(VERSION 3.10)
+project(tsnet_flutter_library VERSION 0.2.0 LANGUAGES C)
+
+# Bundle the pre-built Go shared library with the Flutter app.
+# The .so is built by build_go.sh and placed in this directory.
+install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/libtailscale.so"
+ DESTINATION "${CMAKE_INSTALL_PREFIX}/lib"
+ COMPONENT Runtime)
diff --git a/linux/libtailscale.so b/linux/libtailscale.so
new file mode 100644
index 0000000..ec44333
Binary files /dev/null and b/linux/libtailscale.so differ
diff --git a/pubspec.lock b/pubspec.lock
index 3012409..caf540e 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -49,6 +49,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
+ ffi:
+ dependency: "direct main"
+ description:
+ name: ffi
+ sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
flutter:
dependency: "direct main"
description: flutter
diff --git a/pubspec.yaml b/pubspec.yaml
index 1b3fcb4..80a4b37 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -12,6 +12,7 @@ environment:
dependencies:
flutter:
sdk: flutter
+ ffi: ^2.1.0
dev_dependencies:
flutter_test:
@@ -25,3 +26,8 @@ flutter:
pluginClass: TsnetFlutterPlugin
macos:
pluginClass: TsnetFlutterPlugin
+ android:
+ package: io.svrnty.tsnet_flutter
+ ffiPlugin: true
+ linux:
+ ffiPlugin: true