fix: Add manual TCP connect flow

This commit is contained in:
Janez T
2026-04-18 08:59:36 +02:00
parent b333b3737e
commit f9dfdc2340
10 changed files with 1001 additions and 521 deletions

View File

@@ -5,18 +5,23 @@ import 'package:nsd/nsd.dart';
/// Discovered MeshCore device on the network (TCP/WiFi)
class DiscoveredServer {
final String name;
final String ipAddress;
final int port;
final int responseTime; // milliseconds
const DiscoveredServer({
required this.name,
required this.ipAddress,
required this.port,
required this.responseTime,
});
String get displayName => name.trim().isNotEmpty ? name.trim() : ipAddress;
@override
String toString() => 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
String toString() =>
'DiscoveredServer($displayName @ $ipAddress:$port, ${responseTime}ms)';
@override
bool operator ==(Object other) =>
@@ -46,6 +51,7 @@ class NetworkScannerService {
bool _isScanning = false;
bool get isScanning => _isScanning;
int _scanSession = 0;
List<DiscoveredServer> _cachedServers = [];
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
@@ -88,7 +94,11 @@ class NetworkScannerService {
}
/// Try a raw TCP connect to check if the MeshCore TCP server is listening.
Future<DiscoveredServer?> _checkDevice(String ip, int port) async {
Future<DiscoveredServer?> _checkDevice(
String ip,
int port, {
String? name,
}) async {
final sw = Stopwatch()..start();
Socket? socket;
try {
@@ -101,6 +111,7 @@ class NetworkScannerService {
debugPrint(
'✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)');
return DiscoveredServer(
name: (name ?? ip).trim(),
ipAddress: ip,
port: port,
responseTime: sw.elapsedMilliseconds,
@@ -117,7 +128,10 @@ class NetworkScannerService {
// ── mDNS discovery ─────────────────────────────────────────────────────────
Future<List<DiscoveredServer>> _discoverViaMdns({int? port}) async {
Future<List<DiscoveredServer>> _discoverViaMdns({
int? port,
required int scanSession,
}) async {
final scanPort = port ?? defaultPort;
final found = <DiscoveredServer>[];
@@ -131,12 +145,25 @@ class NetworkScannerService {
);
await Future.delayed(bonjourTimeout);
if (!_isScanSessionActive(scanSession)) {
return found;
}
for (final service in _activeDiscovery?.services ?? []) {
if (!_isScanSessionActive(scanSession)) {
break;
}
for (final addr in service.addresses ?? []) {
if (!_isScanSessionActive(scanSession)) {
break;
}
if (localIps.contains(addr.address)) continue;
final result =
await _checkDevice(addr.address, service.port ?? scanPort);
await _checkDevice(
addr.address,
service.port ?? scanPort,
name: service.name,
);
if (result != null) {
found.add(result);
onServerDiscovered?.call(result);
@@ -163,7 +190,10 @@ class NetworkScannerService {
// ── Port scan fallback ─────────────────────────────────────────────────────
Future<List<DiscoveredServer>> _scanByPort({int? port}) async {
Future<List<DiscoveredServer>> _scanByPort({
int? port,
required int scanSession,
}) async {
final scanPort = port ?? defaultPort;
final found = <DiscoveredServer>[];
@@ -175,10 +205,12 @@ class NetworkScannerService {
'🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort');
int scanned = 0;
for (int i = 0; i < ips.length; i += parallelScans) {
for (int i = 0; i < ips.length && _isScanSessionActive(scanSession); i += parallelScans) {
final batch = ips.skip(i).take(parallelScans).toList();
final results =
await Future.wait(batch.map((ip) => _checkDevice(ip, scanPort)));
await Future.wait(
batch.map((ip) => _checkDevice(ip, scanPort, name: ip)),
);
for (final result in results) {
if (result != null && !localIps.contains(result.ipAddress)) {
@@ -200,28 +232,51 @@ class NetworkScannerService {
Future<List<DiscoveredServer>> scan({int? port}) async {
if (_isScanning) return [];
_isScanning = true;
final scanSession = ++_scanSession;
try {
var found = await _discoverViaMdns(port: port);
if (found.isEmpty) {
var found = await _discoverViaMdns(port: port, scanSession: scanSession);
if (_isScanSessionActive(scanSession) && found.isEmpty) {
debugPrint(
'🔍 [NetworkScanner] mDNS found nothing, falling back to port scan');
found = await _scanByPort(port: port);
found = await _scanByPort(port: port, scanSession: scanSession);
}
if (_isScanSessionActive(scanSession)) {
_cachedServers = found;
}
_cachedServers = found;
return found;
} finally {
_isScanning = false;
if (_scanSession == scanSession) {
_isScanning = false;
}
}
}
/// Verify a previously discovered device is still reachable.
Future<bool> verifyServer(DiscoveredServer server) async {
final result = await _checkDevice(server.ipAddress, server.port);
final result = await _checkDevice(
server.ipAddress,
server.port,
name: server.name,
);
return result != null;
}
void clearCache() => _cachedServers = [];
void stopScan() => _isScanning = false;
bool _isScanSessionActive(int session) => _isScanning && _scanSession == session;
void stopScan() {
_scanSession += 1;
_isScanning = false;
final activeDiscovery = _activeDiscovery;
_activeDiscovery = null;
if (activeDiscovery != null) {
unawaited(
stopDiscovery(activeDiscovery).catchError((Object error) {
debugPrint('⚠️ [NetworkScanner] Failed to stop mDNS discovery: $error');
}),
);
}
}
}

View File

@@ -0,0 +1,102 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class RecentTcpConnection {
final String name;
final String host;
final int port;
final DateTime lastUsedAt;
const RecentTcpConnection({
required this.name,
required this.host,
required this.port,
required this.lastUsedAt,
});
Map<String, Object> toJson() => <String, Object>{
'name': name,
'host': host,
'port': port,
'lastUsedAt': lastUsedAt.toIso8601String(),
};
factory RecentTcpConnection.fromJson(Map<String, dynamic> json) {
return RecentTcpConnection(
name: (json['name'] as String?)?.trim().isNotEmpty == true
? (json['name'] as String).trim()
: ((json['host'] as String?) ?? '').trim(),
host: ((json['host'] as String?) ?? '').trim(),
port: (json['port'] as num?)?.toInt() ?? 0,
lastUsedAt:
DateTime.tryParse((json['lastUsedAt'] as String?) ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
);
}
}
class RecentTcpConnectionsService {
static const String _prefsKey = 'recent_tcp_connections_v1';
static const int _maxEntries = 5;
static Future<List<RecentTcpConnection>> load() async {
final prefs = await SharedPreferences.getInstance();
final storedEntries = prefs.getStringList(_prefsKey) ?? const <String>[];
final connections = <RecentTcpConnection>[];
for (final entry in storedEntries) {
try {
final decoded = jsonDecode(entry);
if (decoded is! Map<String, dynamic>) {
continue;
}
final connection = RecentTcpConnection.fromJson(decoded);
if (connection.host.isEmpty || connection.port <= 0) {
continue;
}
connections.add(connection);
} catch (_) {
continue;
}
}
connections.sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
return connections.take(_maxEntries).toList(growable: false);
}
static Future<List<RecentTcpConnection>> remember({
required String name,
required String host,
required int port,
}) async {
final trimmedHost = host.trim();
if (trimmedHost.isEmpty || port <= 0) {
return load();
}
final normalizedName = name.trim().isNotEmpty ? name.trim() : trimmedHost;
final existing = await load();
final updated = <RecentTcpConnection>[
RecentTcpConnection(
name: normalizedName,
host: trimmedHost,
port: port,
lastUsedAt: DateTime.now(),
),
...existing.where(
(connection) =>
connection.host != trimmedHost || connection.port != port,
),
].take(_maxEntries).toList(growable: false);
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_prefsKey,
updated
.map((connection) => jsonEncode(connection.toJson()))
.toList(growable: false),
);
return updated;
}
}