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. /// /// - iOS/macOS: Swift MethodChannel → C bridge → Go static library /// - Android/Linux: Dart FFI → Go shared library (.so) /// /// Usage: /// ```dart /// final ts = TsnetFlutter(); /// await ts.start(authKey: 'tskey-auth-...'); /// final port = await ts.startProxy('100.64.0.5'); /// // Connect your client to localhost:port — traffic tunnels via WireGuard /// ``` class TsnetFlutter { static const _channel = MethodChannel('tsnet_flutter'); /// 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!; } /// 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 { if (_useFfi) { await _ensureFfi.stopProxy(); } else { await _channel.invokeMethod('stopProxy'); } } /// Disconnect from the Tailnet and clean up. Future stop() async { if (_useFfi) { await _ensureFfi.stop(); } else { await _channel.invokeMethod('stop'); } } /// Get the current Tailscale connection status. Future status() async { String? json; if (_useFfi) { json = await _ensureFfi.status(); } else { json = await _channel.invokeMethod('status'); } if (json == null) { return TailscaleStatus(state: TailscaleState.stopped); } return TailscaleStatus.fromJson(jsonDecode(json) as Map); } /// Get this device's Tailscale IPv4 address (100.x.x.x). Future tailscaleIP() async { if (_useFfi) { return await _ensureFfi.tailscaleIP(); } else { final ip = await _channel.invokeMethod('tailscaleIP'); return ip!; } } } enum TailscaleState { stopped, starting, running, needsLogin, } class TailscaleStatus { final TailscaleState state; final String? selfIP; final String? backendState; final List peers; TailscaleStatus({ required this.state, this.selfIP, this.backendState, this.peers = const [], }); factory TailscaleStatus.fromJson(Map json) { final backendState = json['BackendState'] as String? ?? ''; final state = switch (backendState) { 'Running' => TailscaleState.running, 'Starting' => TailscaleState.starting, 'NeedsLogin' || 'NeedsMachineAuth' => TailscaleState.needsLogin, _ => TailscaleState.stopped, }; final selfNode = json['Self'] as Map?; final selfIP = selfNode?['TailscaleIPs'] is List ? (selfNode!['TailscaleIPs'] as List).firstOrNull as String? : null; final peerMap = json['Peer'] as Map? ?? {}; final peers = peerMap.values .map((p) => TailscalePeer.fromJson(p as Map)) .toList(); return TailscaleStatus( state: state, selfIP: selfIP, backendState: backendState, peers: peers, ); } bool get isRunning => state == TailscaleState.running; } class TailscalePeer { final String hostname; final List tailscaleIPs; final bool online; TailscalePeer({ required this.hostname, required this.tailscaleIPs, required this.online, }); factory TailscalePeer.fromJson(Map json) { final ips = json['TailscaleIPs'] as List? ?? []; return TailscalePeer( hostname: json['HostName'] as String? ?? '', tailscaleIPs: ips.map((ip) => ip as String).toList(), online: json['Online'] as bool? ?? false, ); } }