This commit is contained in:
Forrest 2026-08-06 21:32:28 -04:00
parent b6639fb1c9
commit 0a7af1d813
10 changed files with 743 additions and 35 deletions

View file

@ -3,6 +3,8 @@ import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:bonsoir/bonsoir.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:nsd/nsd.dart';
import 'messages.dart';
@ -33,42 +35,104 @@ class DiscoveredDevice {
String get id => name;
}
/// Wraps `nsd` mDNS registration (advertise) + discovery (browse) behind a
/// small, app-shaped API. One instance owns at most one active registration
/// and one active browse at a time.
/// mDNS registration (advertise) + discovery (browse) behind a small,
/// app-shaped API. One instance owns at most one active registration and one
/// active browse at a time.
///
/// Advertising and browsing are independent: a device that is playing
/// advertises so others can find it, and any device can browse to control one.
/// TXT records carry the [serverKey] and protocol version so the browser can
/// filter to same-server, same-version peers before ever opening a socket.
class RemoteDiscovery {
static const _kServerKey = 'sk';
static const _kVersion = 'v';
///
/// There are two backends behind this interface, chosen by [RemoteDiscovery]'s
/// factory: `nsd` (Bonjour/NSD) on Android/iOS/macOS/Windows, and `bonsoir`
/// (Avahi over D-Bus) on Linux — `nsd` has no Linux implementation. Both speak
/// the same DNS-SD wire format, so a Linux host and a phone controller
/// interoperate.
abstract class RemoteDiscovery {
factory RemoteDiscovery() {
if (!kIsWeb && Platform.isLinux) return _BonsoirDiscovery();
return _NsdDiscovery();
}
Registration? _registration;
Discovery? _discovery;
ServiceListener? _listener;
/// Latest discovered, filtered device list (also replayed as a stream).
List<DiscoveredDevice> get current;
Stream<List<DiscoveredDevice>> get devices;
bool get isAdvertising;
bool get isBrowsing;
/// Publish this device as a controllable player on [port]. Replaces any
/// existing advertisement.
Future<void> advertise({
required String deviceName,
required int port,
required String serverKey,
});
Future<void> stopAdvertising();
/// Start browsing for players on [serverKey]'s network. Emits filtered
/// [DiscoveredDevice] lists on [devices]. Replaces any existing browse.
Future<void> startBrowsing(String serverKey);
Future<void> stopBrowsing();
Future<void> dispose();
}
/// TXT-record keys shared by both backends' wire format.
const _kServerKey = 'sk';
const _kVersion = 'v';
/// Stream plumbing + peer filtering shared by both backends. Keeps the
/// same-server / same-version / not-ourselves rules in one place so the two
/// implementations only differ in how they talk to their platform.
mixin _DiscoveryState {
final StreamController<List<DiscoveredDevice>> _devices =
StreamController<List<DiscoveredDevice>>.broadcast();
List<DiscoveredDevice> _current = const [];
/// The final (possibly conflict-renamed) name of our own advertisement, so
/// the browser can filter it out of its own results.
String? _ownName;
String? _browseServerKey;
final StreamController<List<DiscoveredDevice>> _devices =
StreamController<List<DiscoveredDevice>>.broadcast();
List<DiscoveredDevice> _current = const [];
/// Latest discovered, filtered device list (also replayed as a stream).
List<DiscoveredDevice> get current => _current;
Stream<List<DiscoveredDevice>> get devices => _devices.stream;
bool _accepts(DiscoveredDevice d) {
if (_browseServerKey != null && d.serverKey != _browseServerKey) {
return false; // different Subsonic server — its song ids won't resolve here
}
if (d.version != kRemoteProtocolVersion) return false;
if (_ownName != null && d.name == _ownName) return false; // ourselves
return true;
}
/// Filter [all] to controllable peers and publish the result.
void _emit(Iterable<DiscoveredDevice> all) {
_current = all.where(_accepts).toList();
if (!_devices.isClosed) _devices.add(_current);
}
Future<void> _closeDevices() => _devices.close();
}
/// `nsd`-backed discovery for Android/iOS/macOS/Windows.
class _NsdDiscovery with _DiscoveryState implements RemoteDiscovery {
Registration? _registration;
Discovery? _discovery;
ServiceListener? _listener;
@override
bool get isAdvertising => _registration != null;
@override
bool get isBrowsing => _discovery != null;
// ---- Advertise --------------------------------------------------------
/// Publish this device as a controllable player on [port]. Replaces any
/// existing advertisement.
@override
Future<void> advertise({
required String deviceName,
required int port,
@ -88,6 +152,7 @@ class RemoteDiscovery {
_ownName = reg.service.name;
}
@override
Future<void> stopAdvertising() async {
final reg = _registration;
_registration = null;
@ -103,8 +168,7 @@ class RemoteDiscovery {
// ---- Browse -----------------------------------------------------------
/// Start browsing for players on [serverKey]'s network. Emits filtered
/// [DiscoveredDevice] lists on [devices]. Replaces any existing browse.
@override
Future<void> startBrowsing(String serverKey) async {
await stopBrowsing();
_browseServerKey = serverKey;
@ -121,6 +185,7 @@ class RemoteDiscovery {
_rebuild();
}
@override
Future<void> stopBrowsing() async {
final discovery = _discovery;
final listener = _listener;
@ -138,28 +203,17 @@ class RemoteDiscovery {
}
}
@override
Future<void> dispose() async {
await stopAdvertising();
await stopBrowsing();
await _devices.close();
await _closeDevices();
}
void _rebuild() {
final discovery = _discovery;
if (discovery == null) return;
final out = <DiscoveredDevice>[];
for (final s in discovery.services) {
final dev = _toDevice(s);
if (dev == null) continue;
if (_browseServerKey != null && dev.serverKey != _browseServerKey) {
continue; // different Subsonic server — its song ids won't resolve here
}
if (dev.version != kRemoteProtocolVersion) continue;
if (_ownName != null && s.name == _ownName) continue; // ourselves
out.add(dev);
}
_current = out;
if (!_devices.isClosed) _devices.add(out);
_emit(discovery.services.map(_toDevice).whereType<DiscoveredDevice>());
}
DiscoveredDevice? _toDevice(Service s) {
@ -198,3 +252,165 @@ class RemoteDiscovery {
return utf8.decode(v, allowMalformed: true);
}
}
/// `bonsoir`-backed discovery for Linux (Avahi over D-Bus). `nsd` ships no
/// Linux implementation, so this backend fills the gap while speaking the same
/// DNS-SD service type and TXT keys as [_NsdDiscovery].
///
/// Requires the Avahi daemon to be running; if it isn't, advertise/browse
/// simply surface no peers rather than crashing.
class _BonsoirDiscovery with _DiscoveryState implements RemoteDiscovery {
BonsoirBroadcast? _broadcast;
StreamSubscription<BonsoirBroadcastEvent>? _broadcastSub;
BonsoirDiscovery? _discovery;
StreamSubscription<BonsoirDiscoveryEvent>? _discoverySub;
/// Resolved peers keyed by mDNS instance name; browsing emits found events
/// (no address yet) followed by resolved/updated events that fill in the IP.
final Map<String, DiscoveredDevice> _found = {};
@override
bool get isAdvertising => _broadcast != null;
@override
bool get isBrowsing => _discovery != null;
// ---- Advertise --------------------------------------------------------
@override
Future<void> advertise({
required String deviceName,
required int port,
required String serverKey,
}) async {
await stopAdvertising();
final service = BonsoirService(
name: deviceName,
type: kRemoteServiceType,
port: port,
attributes: {
_kServerKey: serverKey,
_kVersion: '$kRemoteProtocolVersion',
},
);
final broadcast = BonsoirBroadcast(service: service);
await broadcast.initialize();
// Avahi renames on conflict; track the name it actually published so the
// browser can filter our own advertisement out.
_ownName = service.name;
_broadcastSub = broadcast.eventStream?.listen((event) {
if (event is BonsoirBroadcastStartedEvent) {
_ownName = event.service.name;
}
});
await broadcast.start();
_broadcast = broadcast;
}
@override
Future<void> stopAdvertising() async {
await _broadcastSub?.cancel();
_broadcastSub = null;
final broadcast = _broadcast;
_broadcast = null;
_ownName = null;
if (broadcast != null) {
try {
await broadcast.stop();
} catch (_) {
// Already gone / Avahi hiccup — nothing to recover.
}
}
}
// ---- Browse -----------------------------------------------------------
@override
Future<void> startBrowsing(String serverKey) async {
await stopBrowsing();
_browseServerKey = serverKey;
_found.clear();
final BonsoirDiscovery discovery;
try {
discovery = BonsoirDiscovery(type: kRemoteServiceType);
await discovery.initialize();
} catch (_) {
// Avahi unavailable (daemon not running / no D-Bus) — degrade to an
// empty device list rather than surfacing an uncaught async error.
_browseServerKey = null;
return;
}
_discovery = discovery;
_discoverySub = discovery.eventStream?.listen((event) {
switch (event) {
case BonsoirDiscoveryServiceFoundEvent():
// Found but not yet resolved — ask for its address/TXT records.
event.service.resolve(discovery.serviceResolver);
case BonsoirDiscoveryServiceResolvedEvent(:final service) ||
BonsoirDiscoveryServiceUpdatedEvent(:final service):
final dev = _toDevice(service);
if (dev != null) {
_found[service.name] = dev;
_emit(_found.values);
}
case BonsoirDiscoveryServiceLostEvent():
_found.remove(event.service.name);
_emit(_found.values);
default:
break;
}
});
try {
await discovery.start();
} catch (_) {
await stopBrowsing(); // tear down the half-started browse cleanly
return;
}
_emit(_found.values);
}
@override
Future<void> stopBrowsing() async {
await _discoverySub?.cancel();
_discoverySub = null;
_browseServerKey = null;
_found.clear();
_current = const [];
final discovery = _discovery;
_discovery = null;
if (discovery != null) {
try {
await discovery.stop();
} catch (_) {
// Already stopped.
}
}
}
@override
Future<void> dispose() async {
await stopAdvertising();
await stopBrowsing();
await _closeDevices();
}
DiscoveredDevice? _toDevice(BonsoirService s) {
final host = _hostOf(s);
if (host == null) return null; // unresolved (no address yet)
return DiscoveredDevice(
name: s.name,
host: host,
port: s.port,
serverKey: s.attributes[_kServerKey] ?? '',
version: int.tryParse(s.attributes[_kVersion] ?? '') ?? 0,
);
}
/// Prefer a routable IPv4 address; Avahi may also report IPv6 (which contain
/// colons) or link-local addresses we can't dial cleanly.
static String? _hostOf(BonsoirService s) {
final addrs = s.hostAddresses;
if (addrs.isEmpty) return null;
return addrs.firstWhere((a) => !a.contains(':'), orElse: () => addrs.first);
}
}