import 'dart:convert'; import 'package:flutter/services.dart'; /// Embedded Tailscale tsnet client for Flutter. /// /// Provides a WireGuard tunnel to Constellation heaters via Tailscale, /// without requiring the user to install the Tailscale app. /// /// Architecture: Go xcframework (tsnet) ↔ Swift bridge ↔ MethodChannel ↔ Dart /// /// Usage: /// ```dart /// final ts = TailscaleKit(); /// await ts.start(authKey: 'tskey-auth-...'); /// final port = await ts.startProxy('100.64.0.5'); /// grpcService.connect('127.0.0.1', port: port); /// ``` class TailscaleKit { static const _channel = MethodChannel('tailscale_kit'); /// 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, }); } /// 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!; } /// Stop the local TCP proxy. Future stopProxy() async { await _channel.invokeMethod('stopProxy'); } /// Disconnect from the Tailnet and clean up. Future stop() async { await _channel.invokeMethod('stop'); } /// Get the current Tailscale connection status. Future status() async { final 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 { 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, ); } }