diff --git a/frosthaven_assistant/lib/Layout/menus/server_discovery_dialog.dart b/frosthaven_assistant/lib/Layout/menus/server_discovery_dialog.dart new file mode 100644 index 00000000..31e01070 --- /dev/null +++ b/frosthaven_assistant/lib/Layout/menus/server_discovery_dialog.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:frosthaven_assistant/services/network/client.dart'; +import 'package:frosthaven_assistant/services/service_locator.dart'; + +class ServerDiscoveryDialog extends StatefulWidget { + const ServerDiscoveryDialog({super.key}); + + @override + State createState() => _ServerDiscoveryDialogState(); +} + +class _ServerDiscoveryDialogState extends State { + final _client = getIt(); + List _servers = []; + bool _isDiscovering = false; + + @override + void initState() { + super.initState(); + _discoverServers(); + } + + void _discoverServers() { + setState(() { + _isDiscovering = true; + _servers = []; + }); + _client.discoverServers().listen((server) { + setState(() { + if (!_servers.any((s) => s.address == server.address && s.port == server.port)) { + _servers.add(server); + } + }); + }).onDone(() { + setState(() { + _isDiscovering = false; + }); + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Discover Servers'), + content: SizedBox( + width: 400, + height: 300, + child: _isDiscovering && _servers.isEmpty + ? const Center(child: CircularProgressIndicator()) + : _servers.isEmpty + ? const Center(child: Text('No servers found.')) + : ListView.builder( + itemCount: _servers.length, + itemBuilder: (context, index) { + final server = _servers[index]; + return ListTile( + title: Text(server.name), + subtitle: Text('${server.address}:${server.port}'), + onTap: () { + Navigator.of(context).pop(server); + }, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: _isDiscovering ? null : _discoverServers, + child: const Text('Refresh'), + ), + ], + ); + } +} diff --git a/frosthaven_assistant/lib/Layout/menus/settings_menu.dart b/frosthaven_assistant/lib/Layout/menus/settings_menu.dart index f542aecd..38813633 100644 --- a/frosthaven_assistant/lib/Layout/menus/settings_menu.dart +++ b/frosthaven_assistant/lib/Layout/menus/settings_menu.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:frosthaven_assistant/Layout/menus/save_menu.dart'; +import 'package:frosthaven_assistant/Layout/menus/server_discovery_dialog.dart'; import 'package:frosthaven_assistant/Resource/commands/clear_unlocked_classes_command.dart'; import 'package:frosthaven_assistant/Resource/commands/set_ally_deck_in_og_gloom_command.dart'; import 'package:frosthaven_assistant/Resource/commands/track_standees_command.dart'; @@ -443,17 +444,35 @@ class SettingsMenuState extends State { }); }), - Container( - margin: const EdgeInsets.only(top: 10), - width: 200, - height: 40, - child: TextField( - controller: _serverTextController, - decoration: const InputDecoration( - counterText: "", - helperText: "server ip address", - ), - maxLength: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.only(top: 10), + width: 200, + height: 40, + child: TextField( + controller: _serverTextController, + decoration: const InputDecoration( + counterText: "", + helperText: "server ip address", + ), + maxLength: 20), + ), + IconButton( + icon: const Icon(Icons.search), + onPressed: () async { + final result = await showDialog( + context: context, + builder: (context) => const ServerDiscoveryDialog(), + ); + if (result != null) { + _serverTextController.text = result.address; + _portTextController.text = result.port.toString(); + } + }, + ), + ], ), Container( diff --git a/frosthaven_assistant/lib/services/network/client.dart b/frosthaven_assistant/lib/services/network/client.dart index 602db647..bab62206 100644 --- a/frosthaven_assistant/lib/services/network/client.dart +++ b/frosthaven_assistant/lib/services/network/client.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:frosthaven_assistant/services/network/communication.dart'; import 'package:frosthaven_assistant/services/network/network.dart'; +import 'package:multicast_dns/multicast_dns.dart'; import '../../Resource/settings.dart'; import '../../Resource/state/game_state.dart'; @@ -179,4 +180,42 @@ class Client { } _serverResponsive = true; } + + Stream discoverServers() async* { + final MDnsClient client = MDnsClient(); + await client.start(); + + const String name = '_frosthaven._tcp.local'; + await for (final PtrResourceRecord ptr in client.lookup( + ResourceRecordQuery.serverPointer(name), + )) { + await for (final SrvResourceRecord srv in client.lookup( + ResourceRecordQuery.service(ptr.domainName), + )) { + await for (final IPAddressResourceRecord ip + in client.lookup( + ResourceRecordQuery.addressIPv4(srv.target), + )) { + yield DiscoveredServer( + name: ptr.domainName, + address: ip.address.address, + port: srv.port, + ); + } + } + } + client.stop(); + } +} + +class DiscoveredServer { + DiscoveredServer({ + required this.name, + required this.address, + required this.port, + }); + + final String name; + final String address; + final int port; } diff --git a/frosthaven_assistant/pubspec.yaml b/frosthaven_assistant/pubspec.yaml index 6d2e1a98..d43f9167 100644 --- a/frosthaven_assistant/pubspec.yaml +++ b/frosthaven_assistant/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: format: ^1.4.0 frosthaven_assistant_server: path: ../frosthaven_assistant_server + multicast_dns: ^0.3.3 diff --git a/frosthaven_assistant_server/lib/bonjour.dart b/frosthaven_assistant_server/lib/bonjour.dart new file mode 100644 index 00000000..24e24341 --- /dev/null +++ b/frosthaven_assistant_server/lib/bonjour.dart @@ -0,0 +1,148 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:convert'; + +import 'package:multicast_dns/src/constants.dart'; +import 'package:multicast_dns/src/resource_record.dart'; + +class Bonjour { + RawDatagramSocket? _socket; + + Future advertise(String ip, int port) async { + // Stop any existing advertising. + stop(); + + const String serviceName = '_frosthaven._tcp.local'; + final String serverName = 'Frosthaven Assistant Server.$serviceName'; + + _socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, mDnsPort); + _socket!.joinMulticast(mDnsAddressIPv4); + + final PtrResourceRecord ptr = PtrResourceRecord( + serviceName, + 3600, + domainName: serverName, + ); + + final SrvResourceRecord srv = SrvResourceRecord( + serverName, + 3600, + port: port, + target: ip, + weight: 0, + priority: 0, + ); + + final IPAddressResourceRecord ipAddress = IPAddressResourceRecord( + ip, + 3600, + address: InternetAddress(ip), + ); + + final List packet = _buildResponse([ptr, srv, ipAddress]); + _socket!.send(packet, mDnsAddressIPv4, mDnsPort); + } + + void stop() { + _socket?.close(); + _socket = null; + } + + List _buildResponse(List records) { + final writer = _MDnsWriter(isQuery: false); + writer.writeRecords(records); + return writer.toUint8List(); + } +} + +class _MDnsWriter { + _MDnsWriter({this.isQuery = true}) { + // ID is always 0 + _byteData.setUint16(0, 0); + // Flags + _byteData.setUint16(2, isQuery ? 0 : 0x8400); // Authoritative answer + // Question count + _byteData.setUint16(4, 0); + // Answer count + _byteData.setUint16(6, 0); + // Authority count + _byteData.setUint16(8, 0); + // Additional count + _byteData.setUint16(10, 0); + } + + final bool isQuery; + final Uint8List _data = Uint8List(4096); + late final ByteData _byteData = ByteData.view(_data.buffer); + int _offset = 12; + final Map _fqdnOffsets = {}; + + void writeFQDN(String fqdn) { + if (_fqdnOffsets.containsKey(fqdn)) { + final int fqdnOffset = _fqdnOffsets[fqdn]!; + _byteData.setUint16(_offset, 0xc000 | fqdnOffset); + _offset += 2; + return; + } + + _fqdnOffsets[fqdn] = _offset; + final List parts = fqdn.split('.'); + for (final String part in parts) { + final List partBytes = utf8.encode(part); + _data[_offset++] = partBytes.length; + _data.setRange(_offset, _offset + partBytes.length, partBytes); + _offset += partBytes.length; + } + _data[_offset++] = 0; + } + + void writeRecord(ResourceRecord record) { + writeFQDN(record.fullyQualifiedName); + _byteData.setUint16(_offset, record.resourceRecordType); + _offset += 2; + _byteData.setUint16(_offset, ResourceRecordClass.internet | 0x8000); // Cache flush + _offset += 2; + _byteData.setUint32(_offset, record.ttl); + _offset += 4; + + final int dataLengthOffset = _offset; + _offset += 2; + + final int dataStartOffset = _offset; + + if (record is IPAddressResourceRecord) { + final List addressBytes = record.address.rawAddress; + _data.setRange(_offset, _offset + addressBytes.length, addressBytes); + _offset += addressBytes.length; + } else if (record is PtrResourceRecord) { + writeFQDN(record.domainName); + } else if (record is SrvResourceRecord) { + _byteData.setUint16(_offset, record.priority); + _offset += 2; + _byteData.setUint16(_offset, record.weight); + _offset += 2; + _byteData.setUint16(_offset, record.port); + _offset += 2; + writeFQDN(record.target); + } else if (record is TxtResourceRecord) { + final List textBytes = utf8.encode(record.text); + _data[_offset++] = textBytes.length; + _data.setRange(_offset, _offset + textBytes.length, textBytes); + _offset += textBytes.length; + } + + final int dataLength = _offset - dataStartOffset; + _byteData.setUint16(dataLengthOffset, dataLength); + } + + void writeRecords(List records) { + _byteData.setUint16(6, records.length); + for (final ResourceRecord record in records) { + writeRecord(record); + } + } + + Uint8List toUint8List() { + return Uint8List.view(_data.buffer, 0, _offset); + } +} diff --git a/frosthaven_assistant_server/lib/game_server.dart b/frosthaven_assistant_server/lib/game_server.dart index 94906a05..4a5686fa 100644 --- a/frosthaven_assistant_server/lib/game_server.dart +++ b/frosthaven_assistant_server/lib/game_server.dart @@ -48,6 +48,8 @@ abstract class GameServer { void send(String data); String currentStateMessage(String commandDescription); Future getConnectToIP(); + void startAdvertising(String ip, int port); + void stopAdvertising(); void sendPing(); void addClientConnection(Socket client); @@ -84,6 +86,7 @@ abstract class GameServer { 'Server Online: IP: $connectTo, Port: ${server.port.toString()}'; log(info); setNetworkMessage(info); + startAdvertising(connectTo, server.port); resetState(); send(currentStateMessage("")); var subscriptions = server.listen((Socket client) { @@ -119,6 +122,7 @@ abstract class GameServer { serverEnabled = false; leftOverMessage = ""; + stopAdvertising(); resetState(); } diff --git a/frosthaven_assistant_server/lib/standalone_server.dart b/frosthaven_assistant_server/lib/standalone_server.dart index 4aa71b8f..b8391481 100644 --- a/frosthaven_assistant_server/lib/standalone_server.dart +++ b/frosthaven_assistant_server/lib/standalone_server.dart @@ -2,11 +2,15 @@ import 'dart:developer'; import 'dart:io'; +import 'dart:io'; + +import 'package:frosthaven_assistant_server/bonjour.dart'; import 'package:frosthaven_assistant_server/connection_health.dart'; import 'package:frosthaven_assistant_server/game_server.dart'; import 'package:frosthaven_assistant_server/server_state.dart'; class StandaloneServer extends GameServer { + final Bonjour _bonjour = Bonjour(); final List _clientConnections = List.empty(growable: true); final ServerState _state = ServerState(); @@ -243,5 +247,14 @@ class StandaloneServer extends GameServer { return "Closed client: "; } } - + + @override + void startAdvertising(String ip, int port) { + _bonjour.advertise(ip, port); + } + + @override + void stopAdvertising() { + _bonjour.stop(); + } } diff --git a/frosthaven_assistant_server/pubspec.yaml b/frosthaven_assistant_server/pubspec.yaml index 2e128374..dff8ad0c 100644 --- a/frosthaven_assistant_server/pubspec.yaml +++ b/frosthaven_assistant_server/pubspec.yaml @@ -8,6 +8,7 @@ environment: # Add regular dependencies here. dependencies: + multicast_dns: ^0.3.3 # path: ^1.8.0 dev_dependencies: