feat: multi-IP QR code for companion remote

This commit is contained in:
edde746
2026-02-24 02:31:24 +01:00
parent 2276750704
commit 9528b8f61a
5 changed files with 168 additions and 55 deletions
@@ -25,25 +25,28 @@ class RecentRemoteSession {
Map<String, dynamic> 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',
);
}
+18 -6
View File
@@ -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<String> 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<void> joinSession(String sessionId, String pin, String hostAddress) async {
await joinSessionMulti(sessionId, pin, [hostAddress]);
}
Future<void> joinSessionMulti(String sessionId, String pin, List<String> 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());
@@ -144,23 +144,26 @@ class _PairingScreenState extends State<PairingScreen> {
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<PairingScreen> {
}
}
Future<void> _connectWithCredentials(String sessionId, String pin, String hostAddress) async {
Future<void> _connectWithCredentialsMulti(String sessionId, String pin, List<String> hostAddresses) async {
try {
final provider = context.read<CompanionRemoteProvider>();
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();
@@ -86,44 +86,43 @@ class CompanionRemotePeerService {
return List.generate(6, (index) => random.nextInt(10).toString()).join();
}
Future<String> _getLocalIpAddress() async {
Future<List<String>> _getAllLocalIpAddresses() async {
try {
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
// Prefer WiFi interface, then any non-loopback
final preferred = <String>[];
final others = <String>[];
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<String> 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<String> joinSessionRacing(
String sessionId,
String pin,
String deviceName,
String platform,
List<String> 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<String>();
final channels = <IOWebSocketChannel>[];
final subs = <StreamSubscription>[];
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<String, dynamic>;
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), (_) {
@@ -25,7 +25,7 @@ class RemoteSessionDialog extends StatefulWidget {
class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
bool _isCreatingSession = false;
String? _errorMessage;
String? _hostAddress; // Format: "ip:port"
List<String>? _hostAddresses; // Each format: "ip:port"
@override
void initState() {
@@ -38,7 +38,7 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
setState(() {
_isCreatingSession = true;
_errorMessage = null;
_hostAddress = null;
_hostAddresses = null;
});
try {
@@ -48,7 +48,7 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
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<RemoteSessionDialog> {
}
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<RemoteSessionDialog> {
);
}
// 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<RemoteSessionDialog> {
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,