flutter-tsnet/lib/tsnet_flutter.dart
Mathias Beaulieu-Duncan 63f0315ee7 Rename package to tsnet_flutter for pub.dev publishing under Svrnty
- Rename Dart package: tailscale_kit → tsnet_flutter
- Rename Swift plugin class: TailscaleKitPlugin → TsnetFlutterPlugin
- Rename podspec: tailscale_kit → tsnet_flutter
- Update MethodChannel name to tsnet_flutter
- Add BSD-3-Clause LICENSE (compatible with Tailscale's license)
- Update metadata for pub.dev: homepage, repository, author

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 06:10:34 -04:00

151 lines
4.3 KiB
Dart

import 'dart:convert';
import 'package:flutter/services.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
///
/// 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');
/// 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<void> 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:<port>`.
/// Traffic is forwarded through the Tailscale WireGuard tunnel to
/// [heaterTailscaleIP]:[port] on the heater.
///
/// Uses userspace netstack — no VPN entitlement needed on iOS.
Future<int> startProxy(String heaterTailscaleIP, {int port = 5050}) async {
final localPort = await _channel.invokeMethod<int>('startProxy', {
'ip': heaterTailscaleIP,
'port': port,
});
return localPort!;
}
/// Stop the local TCP proxy.
Future<void> stopProxy() async {
await _channel.invokeMethod('stopProxy');
}
/// Disconnect from the Tailnet and clean up.
Future<void> stop() async {
await _channel.invokeMethod('stop');
}
/// Get the current Tailscale connection status.
Future<TailscaleStatus> status() async {
final json = await _channel.invokeMethod<String>('status');
if (json == null) {
return TailscaleStatus(state: TailscaleState.stopped);
}
return TailscaleStatus.fromJson(jsonDecode(json) as Map<String, dynamic>);
}
/// Get this device's Tailscale IPv4 address (100.x.x.x).
Future<String> tailscaleIP() async {
final ip = await _channel.invokeMethod<String>('tailscaleIP');
return ip!;
}
}
enum TailscaleState {
stopped,
starting,
running,
needsLogin,
}
class TailscaleStatus {
final TailscaleState state;
final String? selfIP;
final String? backendState;
final List<TailscalePeer> peers;
TailscaleStatus({
required this.state,
this.selfIP,
this.backendState,
this.peers = const [],
});
factory TailscaleStatus.fromJson(Map<String, dynamic> 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<String, dynamic>?;
final selfIP = selfNode?['TailscaleIPs'] is List
? (selfNode!['TailscaleIPs'] as List).firstOrNull as String?
: null;
final peerMap = json['Peer'] as Map<String, dynamic>? ?? {};
final peers = peerMap.values
.map((p) => TailscalePeer.fromJson(p as Map<String, dynamic>))
.toList();
return TailscaleStatus(
state: state,
selfIP: selfIP,
backendState: backendState,
peers: peers,
);
}
bool get isRunning => state == TailscaleState.running;
}
class TailscalePeer {
final String hostname;
final List<String> tailscaleIPs;
final bool online;
TailscalePeer({
required this.hostname,
required this.tailscaleIPs,
required this.online,
});
factory TailscalePeer.fromJson(Map<String, dynamic> 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,
);
}
}