diff --git a/lib/app/app.dart b/lib/app/app.dart index e6dba4d..f27eda0 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -5,6 +5,7 @@ import 'package:rutorrentflutter/services/api/http_client_service.dart'; import 'package:rutorrentflutter/services/api/i_api_service.dart'; import 'package:rutorrentflutter/services/api/prod_api_service.dart'; import 'package:rutorrentflutter/services/functional_services/authentication_service.dart'; +import 'package:rutorrentflutter/services/functional_services/discovery_service.dart'; import 'package:rutorrentflutter/services/functional_services/disk_space_service.dart'; import 'package:rutorrentflutter/services/functional_services/internet_service.dart'; import 'package:rutorrentflutter/services/functional_services/notification_service.dart'; @@ -71,6 +72,7 @@ import '../ui/views/splash/splash_view.dart'; LazySingleton(classType: FilePickerService), LazySingleton(classType: TorrentService), LazySingleton(classType: PackageInfoService), + LazySingleton(classType: DiscoveryService), LazySingleton(classType: DiskFileService), ], ) diff --git a/lib/app/app.locator.dart b/lib/app/app.locator.dart index c86c566..9690789 100644 --- a/lib/app/app.locator.dart +++ b/lib/app/app.locator.dart @@ -14,6 +14,7 @@ import '../services/api/http_client_service.dart'; import '../services/api/i_api_service.dart'; import '../services/api/prod_api_service.dart'; import '../services/functional_services/authentication_service.dart'; +import '../services/functional_services/discovery_service.dart'; import '../services/functional_services/disk_space_service.dart'; import '../services/functional_services/internet_service.dart'; import '../services/functional_services/notification_service.dart'; @@ -57,5 +58,6 @@ Future setupLocator( locator.registerLazySingleton(() => FilePickerService()); locator.registerLazySingleton(() => TorrentService()); locator.registerLazySingleton(() => PackageInfoService()); + locator.registerLazySingleton(() => DiscoveryService()); locator.registerLazySingleton(() => DiskFileService()); } diff --git a/lib/services/functional_services/discovery_service.dart b/lib/services/functional_services/discovery_service.dart new file mode 100644 index 0000000..5fa9a1d --- /dev/null +++ b/lib/services/functional_services/discovery_service.dart @@ -0,0 +1,293 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; +import 'package:rutorrentflutter/app/app.logger.dart'; + +Logger _log = getLogger("DiscoveryService"); + +/// Represents a discovered ruTorrent server on the local network. +class DiscoveredServer { + final String host; + final int port; + final String protocol; + final String? name; + + DiscoveredServer({ + required this.host, + required this.port, + this.protocol = 'http', + this.name, + }); + + /// Constructs the full URL for connecting to this server. + String get url { + if (port == 80 && protocol == 'http') { + return '$protocol://$host'; + } else if (port == 443 && protocol == 'https') { + return '$protocol://$host'; + } + return '$protocol://$host:$port'; + } + + @override + String toString() => name != null ? '$name ($url)' : url; +} + +/// Service for discovering ruTorrent servers on the local network +/// using mDNS/DNS-SD (Zeroconf). +/// +/// This service broadcasts mDNS queries for the `_rutorrent_mobile._tcp.local` +/// service type and listens for responses from servers that have been +/// configured to advertise via Avahi or similar mDNS responders. +/// +/// ## Server Configuration +/// To make a ruTorrent server discoverable, users need to create an Avahi +/// service file on their server. Example `/etc/avahi/services/rutorrent.service`: +/// ```xml +/// +/// +/// +/// My ruTorrent Server +/// +/// _rutorrent_mobile._tcp +/// 443 +/// protocol=https +/// +/// +/// ``` +class DiscoveryService { + static const String _serviceType = '_rutorrent_mobile._tcp.local'; + static const int _mdnsPort = 5353; + static const String _mdnsAddress = '224.0.0.251'; + static const Duration _discoveryTimeout = Duration(seconds: 5); + + final ValueNotifier> discoveredServers = + ValueNotifier([]); + final ValueNotifier isDiscovering = ValueNotifier(false); + + /// Starts discovering ruTorrent servers on the local network. + /// + /// Sends an mDNS query for the `_rutorrent_mobile._tcp.local` service type + /// and listens for responses for [timeout] duration (default 5 seconds). + /// + /// Returns a list of discovered servers. + Future> discover({Duration? timeout}) async { + final effectiveTimeout = timeout ?? _discoveryTimeout; + isDiscovering.value = true; + discoveredServers.value = []; + + _log.i('Starting mDNS discovery for $_serviceType'); + + try { + // Bind to the mDNS multicast address + final socket = await RawDatagramSocket.bind( + InternetAddress.anyIPv4, + 0, // Let OS pick a port + ); + + // Join the multicast group + socket.joinMulticast(InternetAddress(_mdnsAddress)); + socket.broadcastEnabled = true; + socket.readEventsEnabled = true; + + // Build and send the mDNS query + final query = _buildMdnsQuery(_serviceType); + socket.send( + query, + InternetAddress(_mdnsAddress), + _mdnsPort, + ); + + _log.i('mDNS query sent, listening for responses...'); + + // Collect responses + final List servers = []; + final completer = Completer>(); + + // Set up timeout + Timer(effectiveTimeout, () { + if (!completer.isCompleted) { + socket.close(); + completer.complete(servers); + } + }); + + // Listen for responses + socket.listen((event) { + if (event == RawSocketEvent.read) { + final datagram = socket.receive(); + if (datagram != null) { + try { + final server = _parseMdnsResponse(datagram); + if (server != null && + !servers.any((s) => s.host == server.host && s.port == server.port)) { + servers.add(server); + _log.i('Discovered server: ${server.url}'); + discoveredServers.value = List.from(servers); + } + } catch (e) { + _log.w('Error parsing mDNS response: $e'); + } + } + } + }); + + final result = await completer.future; + + _log.i('Discovery complete. Found ${result.length} server(s).'); + + discoveredServers.value = result; + isDiscovering.value = false; + return result; + } catch (e) { + _log.e('Discovery failed: $e'); + isDiscovering.value = false; + return []; + } + } + + /// Builds a minimal mDNS query packet for the given service type. + List _buildMdnsQuery(String name) { + final List packet = []; + + // Transaction ID (2 bytes) + packet.addAll([0x00, 0x00]); + // Flags: standard query (2 bytes) + packet.addAll([0x00, 0x00]); + // Questions: 1 (2 bytes) + packet.addAll([0x00, 0x01]); + // Answer RRs: 0 (2 bytes) + packet.addAll([0x00, 0x00]); + // Authority RRs: 0 (2 bytes) + packet.addAll([0x00, 0x00]); + // Additional RRs: 0 (2 bytes) + packet.addAll([0x00, 0x00]); + + // Query name + final labels = name.split('.'); + for (final label in labels) { + packet.add(label.length); + packet.addAll(label.codeUnits); + } + packet.add(0x00); // Null terminator + + // Type: PTR (12) + packet.addAll([0x00, 0x0C]); + // Class: IN (1) with unicast-response bit + packet.addAll([0x00, 0x01]); + + return packet; + } + + /// Attempts to parse a discovered server from an mDNS response packet. + /// Returns null if the response doesn't contain valid server information. + DiscoveredServer? _parseMdnsResponse(Datagram datagram) { + final data = datagram.data; + + // Basic validation - minimum DNS header size + if (data.length < 12) return null; + + // Check if this is a response (bit 15 of flags should be 1) + if ((data[2] & 0x80) == 0) return null; + + // Use the sender's address as the host + final host = datagram.address.address; + int port = 80; + String protocol = 'http'; + String? name; + + // Try to extract SRV record (port) and TXT record (protocol) + // by scanning the response data + int offset = 12; + try { + // Skip questions section + final qdCount = (data[4] << 8) | data[5]; + for (int i = 0; i < qdCount && offset < data.length; i++) { + offset = _skipName(data, offset); + offset += 4; // Skip QTYPE and QCLASS + } + + // Parse answer/additional sections for SRV and TXT records + final anCount = (data[6] << 8) | data[7]; + final nsCount = (data[8] << 8) | data[9]; + final arCount = (data[10] << 8) | data[11]; + final totalRecords = anCount + nsCount + arCount; + + for (int i = 0; i < totalRecords && offset < data.length - 2; i++) { + offset = _skipName(data, offset); + if (offset + 10 > data.length) break; + + final type = (data[offset] << 8) | data[offset + 1]; + final rdLength = (data[offset + 8] << 8) | data[offset + 9]; + offset += 10; + + if (offset + rdLength > data.length) break; + + if (type == 33 && rdLength >= 6) { + // SRV record + port = (data[offset + 4] << 8) | data[offset + 5]; + } else if (type == 16) { + // TXT record + final txtData = String.fromCharCodes( + data.sublist(offset, offset + rdLength)); + if (txtData.contains('protocol=https')) { + protocol = 'https'; + } else if (txtData.contains('protocol=http')) { + protocol = 'http'; + } + } else if (type == 12) { + // PTR record - might contain the service name + try { + name = _readName(data, offset); + } catch (_) {} + } + + offset += rdLength; + } + } catch (e) { + _log.w('Error parsing DNS records: $e'); + } + + // Determine protocol from port if not specified in TXT + if (port == 443) protocol = 'https'; + + return DiscoveredServer( + host: host, + port: port, + protocol: protocol, + name: name, + ); + } + + /// Skips a DNS name in the packet, handling compression pointers. + int _skipName(List data, int offset) { + while (offset < data.length) { + final len = data[offset]; + if (len == 0) return offset + 1; + if ((len & 0xC0) == 0xC0) return offset + 2; // Compression pointer + offset += len + 1; + } + return offset; + } + + /// Reads a DNS name from the packet at the given offset. + String _readName(List data, int offset) { + final parts = []; + int maxJumps = 10; + while (offset < data.length && maxJumps > 0) { + final len = data[offset]; + if (len == 0) break; + if ((len & 0xC0) == 0xC0) { + offset = ((len & 0x3F) << 8) | data[offset + 1]; + maxJumps--; + continue; + } + offset++; + if (offset + len > data.length) break; + parts.add(String.fromCharCodes(data.sublist(offset, offset + len))); + offset += len; + } + return parts.join('.'); + } +} diff --git a/lib/ui/views/login/login_view.dart b/lib/ui/views/login/login_view.dart index 807e082..7109656 100644 --- a/lib/ui/views/login/login_view.dart +++ b/lib/ui/views/login/login_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:modal_progress_hud_nsn/modal_progress_hud_nsn.dart'; +import 'package:rutorrentflutter/services/functional_services/discovery_service.dart'; import 'package:rutorrentflutter/theme/app_state_notifier.dart'; import 'package:rutorrentflutter/ui/views/login/login_viewmodel.dart'; import 'package:rutorrentflutter/ui/widgets/dumb_widgets/data_input_widget.dart'; @@ -18,6 +19,139 @@ class LoginView extends StatelessWidget { final FocusNode urlFocus = FocusNode(); final _formKey = GlobalKey(); + void _showDiscoveryDialog( + BuildContext context, LoginViewModel model) async { + // Show dialog immediately with loading state + showModalBottomSheet( + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (bottomSheetContext) { + return StatefulBuilder( + builder: (context, setModalState) { + return Container( + padding: EdgeInsets.all(16), + constraints: BoxConstraints(minHeight: 200), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Discover Servers', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: Icon(Icons.refresh), + onPressed: () { + setModalState(() {}); + model.discoverServers().then((_) { + setModalState(() {}); + }); + }, + ), + ], + ), + SizedBox(height: 8), + Text( + 'Searching for ruTorrent servers on your local network...', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: model.isDiscovering, + builder: (context, isDiscovering, _) { + if (isDiscovering) { + return Center( + child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(), + ), + ); + } + return ValueListenableBuilder>( + valueListenable: model.discoveredServers, + builder: (context, servers, _) { + if (servers.isEmpty) { + return Padding( + padding: EdgeInsets.all(24), + child: Center( + child: Column( + children: [ + Icon( + Icons.search_off, + size: 48, + color: Colors.grey, + ), + SizedBox(height: 8), + Text( + 'No servers found', + style: TextStyle(color: Colors.grey), + ), + SizedBox(height: 4), + Text( + 'Make sure your server is configured\nwith Avahi/mDNS discovery.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: Colors.grey, + ), + ), + ], + ), + ), + ); + } + return ListView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: servers.length, + itemBuilder: (context, index) { + final server = servers[index]; + return ListTile( + leading: Icon( + Icons.dns_outlined, + color: Theme.of(context).primaryColor, + ), + title: Text( + server.name ?? server.host, + style: TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text(server.url), + trailing: Icon(Icons.arrow_forward_ios, + size: 16), + onTap: () { + urlController.text = server.url; + Navigator.pop(bottomSheetContext); + }, + ); + }, + ); + }, + ); + }, + ), + ], + ), + ); + }, + ); + }, + ); + + // Start discovery after showing the dialog + model.discoverServers(); + } + @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( @@ -117,7 +251,35 @@ class LoginView extends StatelessWidget { ), ), ], - ) + ), + SizedBox(height: 8), + // Discover Servers button + TextButton.icon( + onPressed: () => + _showDiscoveryDialog(context, model), + icon: Icon( + Icons.wifi_find, + color: Colors.white, + size: 20, + ), + label: Text( + 'Discover Servers on Network', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + style: TextButton.styleFrom( + padding: EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + side: BorderSide( + color: Colors.white54, width: 1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), ], ), ), diff --git a/lib/ui/views/login/login_viewmodel.dart b/lib/ui/views/login/login_viewmodel.dart index 8dcff6e..2fd409f 100644 --- a/lib/ui/views/login/login_viewmodel.dart +++ b/lib/ui/views/login/login_viewmodel.dart @@ -1,5 +1,6 @@ // ignore_for_file: import_of_legacy_library_into_null_safe +import 'package:flutter/foundation.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:logger/logger.dart'; import 'package:rutorrentflutter/app/app.locator.dart'; @@ -8,6 +9,7 @@ import 'package:rutorrentflutter/app/app.router.dart'; import 'package:rutorrentflutter/models/account.dart'; import 'package:rutorrentflutter/services/api/i_api_service.dart'; import 'package:rutorrentflutter/services/functional_services/authentication_service.dart'; +import 'package:rutorrentflutter/services/functional_services/discovery_service.dart'; import 'package:rutorrentflutter/services/functional_services/internet_service.dart'; import 'package:stacked/stacked.dart'; import 'package:stacked_services/stacked_services.dart'; @@ -20,9 +22,15 @@ class LoginViewModel extends BaseViewModel { locator(); InternetService? _internetService = locator(); IApiService? _apiService = locator(); + DiscoveryService _discoveryService = locator(); Account? _account; + /// Reactive state for discovered servers + ValueNotifier> get discoveredServers => + _discoveryService.discoveredServers; + ValueNotifier get isDiscovering => _discoveryService.isDiscovering; + // validating url through regex bool isValidUrl(String input) { var urlRegex = r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'; @@ -41,6 +49,19 @@ class LoginViewModel extends BaseViewModel { return null; } + /// Starts mDNS discovery for ruTorrent servers on the local network. + Future> discoverServers() async { + log.i('Starting server discovery...'); + final servers = await _discoveryService.discover(); + if (servers.isEmpty) { + Fluttertoast.showToast( + msg: 'No servers found. Make sure your server advertises via mDNS.', + ); + } + notifyListeners(); + return servers; + } + login({String? url, required String? username, String? password}) async { setBusy(true); if (username!.trim().isEmpty) {