feat: multi-IP QR code for companion remote
This commit is contained in:
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user