332 lines
9.4 KiB
Dart
332 lines
9.4 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:collection/collection.dart';
|
|
|
|
import '../../utils/app_logger.dart';
|
|
import '../../utils/udp_broadcast_sockets.dart';
|
|
import 'remote_auth_context.dart';
|
|
import 'remote_auth_service.dart';
|
|
|
|
/// A host discovered on the LAN via UDP broadcast.
|
|
class DiscoveredHost {
|
|
final String authContextId;
|
|
final String clientId;
|
|
final String name;
|
|
final String platform;
|
|
final int port;
|
|
final List<String> ips;
|
|
DateTime lastSeen;
|
|
|
|
DiscoveredHost({
|
|
required this.authContextId,
|
|
required this.clientId,
|
|
required this.name,
|
|
required this.platform,
|
|
required this.port,
|
|
required this.ips,
|
|
DateTime? lastSeen,
|
|
}) : lastSeen = lastSeen ?? DateTime.now();
|
|
|
|
List<String> get addresses => ips.map((ip) => '$ip:$port').toList();
|
|
}
|
|
|
|
/// UDP-based LAN discovery for companion remote.
|
|
///
|
|
/// Hosts broadcast authenticated beacons; clients listen and filter
|
|
/// by matching Plex home membership.
|
|
class LanDiscoveryService {
|
|
static const int discoveryPort = 48633;
|
|
static const int _broadcastIntervalSeconds = 3;
|
|
static const int _staleTimeoutSeconds = 10;
|
|
static const int _beaconVersion = 1;
|
|
|
|
// Broadcaster state (host)
|
|
UdpBroadcastSocketSet? _broadcastSockets;
|
|
Timer? _broadcastTimer;
|
|
|
|
// Listener state (client)
|
|
RawDatagramSocket? _listenSocket;
|
|
StreamSubscription<RawSocketEvent>? _listenSubscription;
|
|
Timer? _staleCleanupTimer;
|
|
int _listenGeneration = 0;
|
|
final Map<String, DiscoveredHost> _discoveredHosts = {};
|
|
final _hostsController = StreamController<List<DiscoveredHost>>.broadcast();
|
|
|
|
/// Whether the broadcaster is currently active.
|
|
bool get isBroadcasting => _broadcastTimer != null;
|
|
|
|
/// Whether the listener is currently active.
|
|
bool get isListening => _listenSocket != null;
|
|
|
|
// ── Host: Broadcasting ──
|
|
|
|
Future<void> startBroadcastingForContexts({
|
|
required List<RemoteAuthContext> contexts,
|
|
required String deviceName,
|
|
required String platform,
|
|
required int wsPort,
|
|
required List<String> ips,
|
|
}) async {
|
|
await stopBroadcasting();
|
|
if (contexts.isEmpty) return;
|
|
|
|
try {
|
|
_broadcastSockets = await UdpBroadcastSockets.bind();
|
|
if (_broadcastSockets!.isEmpty) return;
|
|
|
|
appLogger.d('LanDiscovery: Broadcasting started on port $discoveryPort');
|
|
|
|
// Send immediately, then periodically
|
|
for (final context in contexts) {
|
|
_sendBeacon(context, deviceName, platform, wsPort, ips);
|
|
}
|
|
_broadcastTimer = Timer.periodic(
|
|
const Duration(seconds: _broadcastIntervalSeconds),
|
|
(_) {
|
|
for (final context in contexts) {
|
|
_sendBeacon(context, deviceName, platform, wsPort, ips);
|
|
}
|
|
},
|
|
);
|
|
} catch (e) {
|
|
appLogger.e('LanDiscovery: Failed to start broadcasting', error: e);
|
|
await stopBroadcasting();
|
|
}
|
|
}
|
|
|
|
void _sendBeacon(
|
|
RemoteAuthContext context,
|
|
String deviceName,
|
|
String platform,
|
|
int wsPort,
|
|
List<String> ips,
|
|
) {
|
|
final broadcastSockets = _broadcastSockets;
|
|
if (broadcastSockets == null || broadcastSockets.isEmpty) return;
|
|
|
|
try {
|
|
final auth = RemoteAuthService.instance;
|
|
final discoveryKey = context.discoveryKey;
|
|
final clientId = context.clientIdentifier;
|
|
final homeHash = auth.computeDiscoveryTag(discoveryKey);
|
|
|
|
final beaconHmac = auth.computeBeaconHmac(
|
|
discoveryKey: discoveryKey,
|
|
version: _beaconVersion,
|
|
homeHash: homeHash,
|
|
name: deviceName,
|
|
platform: platform,
|
|
clientId: clientId,
|
|
port: wsPort,
|
|
ips: ips,
|
|
);
|
|
|
|
final packet = jsonEncode({
|
|
'app': 'plezy',
|
|
'v': _beaconVersion,
|
|
'homeHash': homeHash,
|
|
'name': deviceName,
|
|
'platform': platform,
|
|
'clientId': clientId,
|
|
'port': wsPort,
|
|
'ips': ips,
|
|
'hmac': beaconHmac,
|
|
});
|
|
|
|
final data = utf8.encode(packet);
|
|
broadcastSockets.send(
|
|
data,
|
|
UdpBroadcastSockets.limitedBroadcastAddress,
|
|
discoveryPort,
|
|
);
|
|
} catch (e) {
|
|
appLogger.e('LanDiscovery: Failed to send beacon', error: e);
|
|
}
|
|
}
|
|
|
|
Future<void> stopBroadcasting() async {
|
|
_broadcastTimer?.cancel();
|
|
_broadcastTimer = null;
|
|
await _broadcastSockets?.close();
|
|
_broadcastSockets = null;
|
|
appLogger.d('LanDiscovery: Broadcasting stopped');
|
|
}
|
|
|
|
// ── Client: Listening ──
|
|
|
|
Stream<List<DiscoveredHost>> startListeningForContexts(
|
|
List<RemoteAuthContext> contexts,
|
|
) {
|
|
_stopListeningInternal();
|
|
_discoveredHosts.clear();
|
|
final generation = _listenGeneration;
|
|
|
|
unawaited(_bindListener(contexts, generation));
|
|
// Periodically remove stale hosts
|
|
_staleCleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
|
final now = DateTime.now();
|
|
final staleIds = <String>[];
|
|
for (final entry in _discoveredHosts.entries) {
|
|
if (now.difference(entry.value.lastSeen).inSeconds >
|
|
_staleTimeoutSeconds) {
|
|
staleIds.add(entry.key);
|
|
}
|
|
}
|
|
if (staleIds.isNotEmpty) {
|
|
for (final id in staleIds) {
|
|
_discoveredHosts.remove(id);
|
|
}
|
|
_emitHosts();
|
|
}
|
|
});
|
|
|
|
return _hostsController.stream;
|
|
}
|
|
|
|
Future<void> _bindListener(
|
|
List<RemoteAuthContext> contexts,
|
|
int generation,
|
|
) async {
|
|
try {
|
|
final socket = await RawDatagramSocket.bind(
|
|
InternetAddress.anyIPv4,
|
|
discoveryPort,
|
|
reuseAddress: true,
|
|
reusePort: true,
|
|
);
|
|
if (generation != _listenGeneration) {
|
|
socket.close();
|
|
return;
|
|
}
|
|
_listenSocket = socket;
|
|
appLogger.d('LanDiscovery: Listening on port $discoveryPort');
|
|
|
|
_listenSubscription = socket.listenDatagrams(
|
|
(datagram) => _handleDatagram(datagram, contexts),
|
|
debugLabel: 'LanDiscovery listener',
|
|
);
|
|
} catch (e) {
|
|
if (generation == _listenGeneration) {
|
|
appLogger.e('LanDiscovery: Failed to bind listener', error: e);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleDatagram(Datagram datagram, List<RemoteAuthContext> contexts) {
|
|
try {
|
|
final packet = utf8.decode(datagram.data);
|
|
final json = jsonDecode(packet) as Map<String, dynamic>;
|
|
|
|
if (json['app'] != 'plezy') return;
|
|
|
|
final version = json['v'] as int? ?? 0;
|
|
final homeHash = json['homeHash'] as String? ?? '';
|
|
final name = json['name'] as String? ?? '';
|
|
final platform = json['platform'] as String? ?? '';
|
|
final clientId = json['clientId'] as String? ?? '';
|
|
final port = json['port'] as int? ?? 0;
|
|
final ips = (json['ips'] as List<dynamic>?)?.cast<String>() ?? [];
|
|
final hmac = json['hmac'] as String? ?? '';
|
|
|
|
final auth = RemoteAuthService.instance;
|
|
RemoteAuthContext? matchedContext;
|
|
for (final context in contexts) {
|
|
final discoveryKey = context.discoveryKey;
|
|
if (!auth.verifyBeaconHmac(
|
|
receivedHmac: hmac,
|
|
discoveryKey: discoveryKey,
|
|
version: version,
|
|
homeHash: homeHash,
|
|
name: name,
|
|
platform: platform,
|
|
clientId: clientId,
|
|
port: port,
|
|
ips: ips,
|
|
)) {
|
|
continue;
|
|
}
|
|
if (!auth.matchesDiscoveryTag(homeHash, discoveryKey)) {
|
|
continue;
|
|
}
|
|
matchedContext = context;
|
|
break;
|
|
}
|
|
if (matchedContext == null) {
|
|
return; // Different home
|
|
}
|
|
|
|
// Valid beacon from same home. Normalize only after authentication so
|
|
// stored endpoint order matches the canonical HMAC representation.
|
|
final normalizedIps = List<String>.from(ips)..sort();
|
|
final lastSeen = DateTime.now();
|
|
final hostKey = clientId;
|
|
final existing = _discoveredHosts[hostKey];
|
|
if (existing != null) {
|
|
final hostChanged =
|
|
existing.name != name ||
|
|
existing.platform != platform ||
|
|
existing.port != port ||
|
|
!const ListEquality<String>().equals(existing.ips, normalizedIps);
|
|
if (hostChanged) {
|
|
_discoveredHosts[hostKey] = DiscoveredHost(
|
|
authContextId: existing.authContextId,
|
|
clientId: clientId,
|
|
name: name,
|
|
platform: platform,
|
|
port: port,
|
|
ips: normalizedIps,
|
|
lastSeen: lastSeen,
|
|
);
|
|
_emitHosts();
|
|
} else {
|
|
existing.lastSeen = lastSeen;
|
|
}
|
|
} else {
|
|
_discoveredHosts[hostKey] = DiscoveredHost(
|
|
authContextId: matchedContext.id,
|
|
clientId: clientId,
|
|
name: name,
|
|
platform: platform,
|
|
port: port,
|
|
ips: normalizedIps,
|
|
lastSeen: lastSeen,
|
|
);
|
|
appLogger.d(
|
|
'LanDiscovery: Discovered host: $name ($platform) at ${normalizedIps.join(", ")}:$port',
|
|
);
|
|
_emitHosts();
|
|
}
|
|
} catch (e) {
|
|
// Ignore malformed packets
|
|
}
|
|
}
|
|
|
|
void _emitHosts() {
|
|
_hostsController.add(_discoveredHosts.values.toList());
|
|
}
|
|
|
|
void stopListening() {
|
|
_stopListeningInternal();
|
|
_discoveredHosts.clear();
|
|
_emitHosts();
|
|
}
|
|
|
|
void _stopListeningInternal() {
|
|
++_listenGeneration;
|
|
_staleCleanupTimer?.cancel();
|
|
_staleCleanupTimer = null;
|
|
_listenSubscription?.cancel();
|
|
_listenSubscription = null;
|
|
_listenSocket?.close();
|
|
_listenSocket = null;
|
|
appLogger.d('LanDiscovery: Listening stopped');
|
|
}
|
|
|
|
void dispose() {
|
|
stopBroadcasting();
|
|
stopListening();
|
|
_hostsController.close();
|
|
}
|
|
}
|