diff --git a/lib/models/companion_remote/recent_remote_session.dart b/lib/models/companion_remote/recent_remote_session.dart index 43e0ecc9..146aa697 100644 --- a/lib/models/companion_remote/recent_remote_session.dart +++ b/lib/models/companion_remote/recent_remote_session.dart @@ -25,25 +25,28 @@ class RecentRemoteSession { Map toJson() => _$RecentRemoteSessionToJson(this); - /// Create from QR code data (format: "ip|port|sessionId|pin") + /// Create from QR code data (format: "ip1,ip2|port|sessionId|pin" or legacy "ip|port|sessionId|pin") factory RecentRemoteSession.fromQrData(String qrData) { final parts = qrData.split('|'); if (parts.length < 4) { throw FormatException('Invalid QR code format - expected ip|port|sessionId|pin'); } - final ip = parts.first; + final ipsField = parts.first; final port = parts[1]; final sessionId = parts[2]; final pin = parts[3]; + // Use the first IP for storage (comma-separated IPs supported in QR) + final firstIp = ipsField.split(',').first; + return RecentRemoteSession( sessionId: sessionId, pin: pin, deviceName: 'Unknown Device', platform: 'unknown', lastConnected: DateTime.now(), - hostAddress: '$ip:$port', + hostAddress: '$firstIp:$port', ); } diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index b27308d4..7bb4f6cd 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -205,7 +205,7 @@ class CompanionRemoteProvider with ChangeNotifier { _statusSubscription = null; } - Future<({String sessionId, String pin, String address})> createSession() async { + Future<({String sessionId, String pin, List addresses})> createSession() async { await leaveSession(); appLogger.d('CompanionRemote: Creating session as host'); @@ -225,7 +225,7 @@ class CompanionRemoteProvider with ChangeNotifier { notifyListeners(); appLogger.d( - 'CompanionRemote: Session created - ID: ${result.sessionId}, PIN: ${result.pin}, Address: ${result.address}', + 'CompanionRemote: Session created - ID: ${result.sessionId}, PIN: ${result.pin}, Addresses: ${result.addresses}', ); return result; @@ -244,13 +244,18 @@ class CompanionRemoteProvider with ChangeNotifier { } Future joinSession(String sessionId, String pin, String hostAddress) async { + await joinSessionMulti(sessionId, pin, [hostAddress]); + } + + Future joinSessionMulti(String sessionId, String pin, List hostAddresses) async { await leaveSession(); _lastSessionId = sessionId; _lastPin = pin; - _lastHostAddress = hostAddress; + // Store first address as fallback for reconnection; will be updated with winner + _lastHostAddress = hostAddresses.first; - appLogger.d('CompanionRemote: Joining session - ID: $sessionId, Host: $hostAddress'); + appLogger.d('CompanionRemote: Joining session - ID: $sessionId, Hosts: $hostAddresses'); _peerService = CompanionRemotePeerService(); _setupPeerServiceListeners(); @@ -264,11 +269,18 @@ class CompanionRemoteProvider with ChangeNotifier { notifyListeners(); try { - await _peerService!.joinSession(sessionId, pin, _deviceName, _platform, hostAddress); + final winner = await _peerService!.joinSessionRacing( + sessionId, + pin, + _deviceName, + _platform, + hostAddresses, + ); + _lastHostAddress = winner; _session = _session?.copyWith(status: RemoteSessionStatus.connected); notifyListeners(); - appLogger.d('CompanionRemote: Successfully joined session'); + appLogger.d('CompanionRemote: Successfully joined session via $winner'); } catch (e) { appLogger.e('CompanionRemote: Failed to join session', error: e); _session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: e.toString()); diff --git a/lib/screens/companion_remote/pairing_screen.dart b/lib/screens/companion_remote/pairing_screen.dart index 2539be5b..a8ac03cd 100644 --- a/lib/screens/companion_remote/pairing_screen.dart +++ b/lib/screens/companion_remote/pairing_screen.dart @@ -144,23 +144,26 @@ class _PairingScreenState extends State { if (data == _lastScannedCode) return; _lastScannedCode = data; - // Strip URL wrapper if present (e.g. "https://plezy.app/scan#ip|port|sid|pin") + // Strip URL wrapper if present (e.g. "https://plezy.app/scan#ip1,ip2|port|sid|pin") final payload = data.contains('#') ? data.split('#').last : data; final parts = payload.split('|'); if (parts.length == 4) { - final ip = parts.first; + final ipsField = parts.first; final port = parts[1]; final sessionId = parts[2]; final pin = parts[3]; - final hostAddress = '$ip:$port'; + + // Support comma-separated IPs (multi-NIC) or single IP (legacy) + final ips = ipsField.split(','); + final hostAddresses = ips.map((ip) => '$ip:$port').toList(); _scannerController?.stop(); setState(() { _errorMessage = null; _isConnecting = true; }); - // Connect directly instead of going through _connect() which requires Form validation - _connectWithCredentials(sessionId, pin, hostAddress); + + _connectWithCredentialsMulti(sessionId, pin, hostAddresses); } else { setState(() { _errorMessage = t.companionRemote.pairing.invalidQrCode; @@ -168,10 +171,14 @@ class _PairingScreenState extends State { } } - Future _connectWithCredentials(String sessionId, String pin, String hostAddress) async { + Future _connectWithCredentialsMulti(String sessionId, String pin, List hostAddresses) async { try { final provider = context.read(); - await provider.joinSession(sessionId.trim().toUpperCase(), pin.trim(), hostAddress.trim()); + await provider.joinSessionMulti( + sessionId.trim().toUpperCase(), + pin.trim(), + hostAddresses.map((a) => a.trim()).toList(), + ); if (mounted) { Navigator.of(context).pop(); diff --git a/lib/services/companion_remote/companion_remote_peer_service.dart b/lib/services/companion_remote/companion_remote_peer_service.dart index 460c353e..fb6f0559 100644 --- a/lib/services/companion_remote/companion_remote_peer_service.dart +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -86,44 +86,43 @@ class CompanionRemotePeerService { return List.generate(6, (index) => random.nextInt(10).toString()).join(); } - Future _getLocalIpAddress() async { + Future> _getAllLocalIpAddresses() async { try { final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4); - // Prefer WiFi interface, then any non-loopback + final preferred = []; + final others = []; + for (final interface in interfaces) { - // Skip loopback if (interface.name.toLowerCase().contains('lo')) continue; for (final addr in interface.addresses) { if (!addr.isLoopback && addr.type == InternetAddressType.IPv4) { - // Prefer names that suggest WiFi/Ethernet - if (interface.name.toLowerCase().contains('en') || - interface.name.toLowerCase().contains('wl') || - interface.name.toLowerCase().contains('eth')) { - return addr.address; + final name = interface.name.toLowerCase(); + if (name.contains('en') || name.contains('wl') || name.contains('eth')) { + preferred.add(addr.address); + } else { + others.add(addr.address); } } } } - // Fallback: return any non-loopback IPv4 - for (final interface in interfaces) { - for (final addr in interface.addresses) { - if (!addr.isLoopback && addr.type == InternetAddressType.IPv4) { - return addr.address; - } - } + final all = [...preferred, ...others]; + if (all.isEmpty) { + throw const RemotePeerError(type: RemotePeerErrorType.networkError, message: 'No network interface found'); } - - throw const RemotePeerError(type: RemotePeerErrorType.networkError, message: 'No network interface found'); + return all; } catch (e) { - appLogger.e('CompanionRemote: Failed to get local IP', error: e); + appLogger.e('CompanionRemote: Failed to get local IPs', error: e); rethrow; } } - Future<({String sessionId, String pin, String address})> createSession(String deviceName, String platform) async { + Future<({String sessionId, String pin, List addresses})> createSession( + String deviceName, + String platform, + ) async { if (_server != null) { await disconnect(); } @@ -145,11 +144,12 @@ class CompanionRemotePeerService { _server = await HttpServer.bind(InternetAddress.anyIPv4, 0); } - final localIp = await _getLocalIpAddress(); + final localIps = await _getAllLocalIpAddresses(); final port = _server!.port; - _hostAddress = '$localIp:$port'; + final addresses = localIps.map((ip) => '$ip:$port').toList(); + _hostAddress = addresses.first; - appLogger.d('CompanionRemote: Host server started at $_hostAddress'); + appLogger.d('CompanionRemote: Host server started, addresses: $addresses'); // Listen for WebSocket connections _server!.listen((HttpRequest request) async { @@ -168,7 +168,7 @@ class CompanionRemotePeerService { _connectionStateController.add(RemoteSessionStatus.connected); - return (sessionId: _sessionId!, pin: _pin!, address: _hostAddress!); + return (sessionId: _sessionId!, pin: _pin!, addresses: addresses); } catch (e) { appLogger.e('CompanionRemote: Failed to create server', error: e); _errorController.add( @@ -441,6 +441,94 @@ class CompanionRemotePeerService { ); } + /// Race WebSocket connections to multiple host addresses in parallel. + /// Returns the winning address and sets up a proper managed connection via [joinSession]. + Future joinSessionRacing( + String sessionId, + String pin, + String deviceName, + String platform, + List hostAddresses, + ) async { + if (hostAddresses.length == 1) { + await joinSession(sessionId, pin, deviceName, platform, hostAddresses.first); + return hostAddresses.first; + } + + appLogger.d('CompanionRemote: Racing connections to ${hostAddresses.length} addresses'); + + final completer = Completer(); + final channels = []; + final subs = []; + + void cleanup() { + for (final sub in subs) { + sub.cancel(); + } + for (final ch in channels) { + try { + ch.sink.close(); + } catch (_) {} + } + } + + for (final address in hostAddresses) { + try { + final url = 'ws://$address/ws'; + final channel = IOWebSocketChannel.connect(Uri.parse(url), connectTimeout: const Duration(seconds: 5)); + channels.add(channel); + + // Send auth immediately + channel.sink.add(jsonEncode({ + 'type': 'auth', + 'sessionId': sessionId.toUpperCase(), + 'pin': pin, + 'deviceName': deviceName, + 'platform': platform, + })); + + final sub = channel.stream.listen( + (data) { + try { + final json = jsonDecode(data as String) as Map; + if (json['type'] == 'authSuccess' && !completer.isCompleted) { + appLogger.d('CompanionRemote: Race winner: $address'); + completer.complete(address); + } + } catch (_) {} + }, + onError: (_) {}, + onDone: () {}, + ); + subs.add(sub); + } catch (e) { + appLogger.d('CompanionRemote: Race candidate $address failed to start: $e'); + } + } + + if (channels.isEmpty) { + throw const RemotePeerError( + type: RemotePeerErrorType.connectionFailed, + message: 'Failed to connect to any address', + ); + } + + try { + final winner = await completer.future.timeout(const Duration(seconds: 10)); + cleanup(); + + // Now set up the proper managed connection on the winning address + await joinSession(sessionId, pin, deviceName, platform, winner); + return winner; + } on TimeoutException { + cleanup(); + throw const RemotePeerError( + type: RemotePeerErrorType.timeout, + message: 'Timed out connecting to all addresses', + ); + } + } + void _startPingTimer() { _stopPingTimer(); _pingTimer = Timer.periodic(const Duration(seconds: 5), (_) { diff --git a/lib/widgets/companion_remote/remote_session_dialog.dart b/lib/widgets/companion_remote/remote_session_dialog.dart index 4832ecbe..5d0bd440 100644 --- a/lib/widgets/companion_remote/remote_session_dialog.dart +++ b/lib/widgets/companion_remote/remote_session_dialog.dart @@ -25,7 +25,7 @@ class RemoteSessionDialog extends StatefulWidget { class _RemoteSessionDialogState extends State { bool _isCreatingSession = false; String? _errorMessage; - String? _hostAddress; // Format: "ip:port" + List? _hostAddresses; // Each format: "ip:port" @override void initState() { @@ -38,7 +38,7 @@ class _RemoteSessionDialogState extends State { setState(() { _isCreatingSession = true; _errorMessage = null; - _hostAddress = null; + _hostAddresses = null; }); try { @@ -48,7 +48,7 @@ class _RemoteSessionDialogState extends State { if (!mounted) return; setState(() { _isCreatingSession = false; - _hostAddress = result.address; + _hostAddresses = result.addresses; }); } catch (e) { appLogger.e('Failed to create companion remote session', error: e); @@ -110,7 +110,7 @@ class _RemoteSessionDialogState extends State { } final session = provider.session; - if (session == null || _hostAddress == null) { + if (session == null || _hostAddresses == null || _hostAddresses!.isEmpty) { return AlertDialog( title: Text(t.common.error), content: Text(t.companionRemote.session.noSession), @@ -118,13 +118,12 @@ class _RemoteSessionDialogState extends State { ); } - // Parse IP and port from hostAddress - final addressParts = _hostAddress!.split(':'); - final ip = addressParts.first; - final port = addressParts[1]; + // Extract port from first address (all share the same port) + final port = _hostAddresses!.first.split(':').last; + final ips = _hostAddresses!.map((a) => a.split(':').first).join(','); - // URL-wrapped QR format so external scanners open a real webpage - final qrData = 'https://plezy.app/scan#$ip|$port|${session.sessionId}|${session.pin}'; + // URL-wrapped QR format: comma-separated IPs for multi-NIC support + final qrData = 'https://plezy.app/scan#$ips|$port|${session.sessionId}|${session.pin}'; return Dialog( child: ConstrainedBox( @@ -193,13 +192,17 @@ class _RemoteSessionDialogState extends State { textAlign: TextAlign.center, ), const SizedBox(height: 16), - _buildCodeCard( - context, - t.companionRemote.session.hostAddress, - _hostAddress!, - onCopy: () => _copyToClipboard(_hostAddress!, t.companionRemote.session.hostAddress), + ..._hostAddresses!.map( + (addr) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildCodeCard( + context, + t.companionRemote.session.hostAddress, + addr, + onCopy: () => _copyToClipboard(addr, t.companionRemote.session.hostAddress), + ), + ), ), - const SizedBox(height: 12), _buildCodeCard( context, t.companionRemote.session.sessionId,