// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'package:meta/meta.dart'; import 'package:multicast_dns/multicast_dns.dart'; import 'base/common.dart'; import 'base/context.dart'; import 'base/io.dart'; import 'base/logger.dart'; import 'build_info.dart'; import 'convert.dart'; import 'device.dart'; import 'reporting/reporting.dart'; /// A wrapper around [MDnsClient] to find a Dart VM Service instance. class MDnsVmServiceDiscovery { /// Creates a new [MDnsVmServiceDiscovery] object. /// /// The [_client] parameter will be defaulted to a new [MDnsClient] if null. MDnsVmServiceDiscovery({ MDnsClient? mdnsClient, MDnsClient? preliminaryMDnsClient, required Logger logger, required Usage flutterUsage, }) : _client = mdnsClient ?? MDnsClient(), _preliminaryClient = preliminaryMDnsClient, _logger = logger, _flutterUsage = flutterUsage; final MDnsClient _client; // Used when discovering VM services with `queryForAttach` to do a preliminary // check for already running services so that results are not cached in _client. final MDnsClient? _preliminaryClient; final Logger _logger; final Usage _flutterUsage; @visibleForTesting static const String dartVmServiceName = '_dartVmService._tcp.local'; static MDnsVmServiceDiscovery? get instance => context.get<MDnsVmServiceDiscovery>(); /// Executes an mDNS query for Dart VM Services. /// Checks for services that have already been launched. /// If none are found, it will listen for new services to become active /// and return the first it finds that match the parameters. /// /// The [applicationId] parameter may be used to specify which application /// to find. For Android, it refers to the package name; on iOS, it refers to /// the bundle ID. /// /// The [deviceVmservicePort] parameter may be used to specify which port /// to find. /// /// The [isNetworkDevice] parameter flags whether to get the device IP /// and the [ipv6] parameter flags whether to get an iPv6 address /// (otherwise it will get iPv4). /// /// The [timeout] parameter determines how long to continue to wait for /// services to become active. /// /// If [applicationId] is not null, this method will find the port and authentication code /// of the Dart VM Service for that application. If it cannot find a service matching /// that application identifier after the [timeout], it will call [throwToolExit]. /// /// If [applicationId] is null and there are multiple Dart VM Services available, /// the user will be prompted with a list of available services with the respective /// app-id and device-vmservice-port to use and asked to select one. /// /// If it is null and there is only one available or it's the first found instance /// of Dart VM Service, it will return that instance's information regardless of /// what application the service instance is for. @visibleForTesting Future<MDnsVmServiceDiscoveryResult?> queryForAttach({ String? applicationId, int? deviceVmservicePort, bool ipv6 = false, bool isNetworkDevice = false, Duration timeout = const Duration(minutes: 10), }) async { // Poll for 5 seconds to see if there are already services running. // Use a new instance of MDnsClient so results don't get cached in _client. // If no results are found, poll for a longer duration to wait for connections. // If more than 1 result is found, throw an error since it can't be determined which to pick. // If only one is found, return it. final List<MDnsVmServiceDiscoveryResult> results = await _pollingVmService( _preliminaryClient ?? MDnsClient(), applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, ipv6: ipv6, isNetworkDevice: isNetworkDevice, timeout: const Duration(seconds: 5), ); if (results.isEmpty) { return firstMatchingVmService( _client, applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, ipv6: ipv6, isNetworkDevice: isNetworkDevice, timeout: timeout, ); } else if (results.length > 1) { final StringBuffer buffer = StringBuffer(); buffer.writeln('There are multiple Dart VM Services available.'); buffer.writeln('Rerun this command with one of the following passed in as the app-id and device-vmservice-port:'); buffer.writeln(); for (final MDnsVmServiceDiscoveryResult result in results) { buffer.writeln( ' flutter attach --app-id "${result.domainName.replaceAll('.$dartVmServiceName', '')}" --device-vmservice-port ${result.port}'); } throwToolExit(buffer.toString()); } return results.first; } /// Executes an mDNS query for Dart VM Services. /// Listens for new services to become active and returns the first it finds that /// match the parameters. /// /// The [applicationId] parameter must be set to specify which application /// to find. For Android, it refers to the package name; on iOS, it refers to /// the bundle ID. /// /// The [deviceVmservicePort] parameter must be set to specify which port /// to find. /// /// [applicationId] and [deviceVmservicePort] are required for launch so that /// if multiple flutter apps are running on different devices, it will /// only match with the device running the desired app. /// /// The [isNetworkDevice] parameter flags whether to get the device IP /// and the [ipv6] parameter flags whether to get an iPv6 address /// (otherwise it will get iPv4). /// /// The [timeout] parameter determines how long to continue to wait for /// services to become active. /// /// If a Dart VM Service matching the [applicationId] and [deviceVmservicePort] /// cannot be found after the [timeout], it will call [throwToolExit]. @visibleForTesting Future<MDnsVmServiceDiscoveryResult?> queryForLaunch({ required String applicationId, required int deviceVmservicePort, bool ipv6 = false, bool isNetworkDevice = false, Duration timeout = const Duration(minutes: 10), }) async { // Query for a specific application and device port. return firstMatchingVmService( _client, applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, ipv6: ipv6, isNetworkDevice: isNetworkDevice, timeout: timeout, ); } /// Polls for Dart VM Services and returns the first it finds that match /// the [applicationId]/[deviceVmservicePort] (if applicable). /// Returns null if no results are found. @visibleForTesting Future<MDnsVmServiceDiscoveryResult?> firstMatchingVmService( MDnsClient client, { String? applicationId, int? deviceVmservicePort, bool ipv6 = false, bool isNetworkDevice = false, Duration timeout = const Duration(minutes: 10), }) async { final List<MDnsVmServiceDiscoveryResult> results = await _pollingVmService( client, applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, ipv6: ipv6, isNetworkDevice: isNetworkDevice, timeout: timeout, quitOnFind: true, ); if (results.isEmpty) { return null; } return results.first; } Future<List<MDnsVmServiceDiscoveryResult>> _pollingVmService( MDnsClient client, { String? applicationId, int? deviceVmservicePort, bool ipv6 = false, bool isNetworkDevice = false, required Duration timeout, bool quitOnFind = false, }) async { _logger.printTrace('Checking for advertised Dart VM Services...'); try { await client.start(); final List<MDnsVmServiceDiscoveryResult> results = <MDnsVmServiceDiscoveryResult>[]; // uniqueDomainNames is used to track all domain names of Dart VM services // It is later used in this function to determine whether or not to throw an error. // We do not want to throw the error if it was unable to find any domain // names because that indicates it may be a problem with mDNS, which has // a separate error message in _checkForIPv4LinkLocal. final Set<String> uniqueDomainNames = <String>{}; // uniqueDomainNamesInResults is used to filter out duplicates with exactly // the same domain name from the results. final Set<String> uniqueDomainNamesInResults = <String>{}; // Listen for mDNS connections until timeout. final Stream<PtrResourceRecord> ptrResourceStream = client.lookup<PtrResourceRecord>( ResourceRecordQuery.serverPointer(dartVmServiceName), timeout: timeout ); await for (final PtrResourceRecord ptr in ptrResourceStream) { uniqueDomainNames.add(ptr.domainName); String? domainName; if (applicationId != null) { // If applicationId is set, only use records that match it if (ptr.domainName.toLowerCase().startsWith(applicationId.toLowerCase())) { domainName = ptr.domainName; } else { continue; } } else { domainName = ptr.domainName; } // Result with same domain name was already found, skip it. if (uniqueDomainNamesInResults.contains(domainName)) { continue; } _logger.printTrace('Checking for available port on $domainName'); final List<SrvResourceRecord> srvRecords = await client .lookup<SrvResourceRecord>( ResourceRecordQuery.service(domainName), ) .toList(); if (srvRecords.isEmpty) { continue; } // If more than one SrvResourceRecord found, it should just be a duplicate. final SrvResourceRecord srvRecord = srvRecords.first; if (srvRecords.length > 1) { _logger.printWarning( 'Unexpectedly found more than one Dart VM Service report for $domainName ' '- using first one (${srvRecord.port}).'); } // If deviceVmservicePort is set, only use records that match it if (deviceVmservicePort != null && srvRecord.port != deviceVmservicePort) { continue; } // Get the IP address of the service if using a network device. InternetAddress? ipAddress; if (isNetworkDevice) { List<IPAddressResourceRecord> ipAddresses = await client .lookup<IPAddressResourceRecord>( ipv6 ? ResourceRecordQuery.addressIPv6(srvRecord.target) : ResourceRecordQuery.addressIPv4(srvRecord.target), ) .toList(); if (ipAddresses.isEmpty) { throwToolExit('Did not find IP for service ${srvRecord.target}.'); } // Filter out link-local addresses. if (ipAddresses.length > 1) { ipAddresses = ipAddresses.where((IPAddressResourceRecord element) => !element.address.isLinkLocal).toList(); } ipAddress = ipAddresses.first.address; if (ipAddresses.length > 1) { _logger.printWarning( 'Unexpectedly found more than one IP for Dart VM Service ${srvRecord.target} ' '- using first one ($ipAddress).'); } } _logger.printTrace('Checking for authentication code for $domainName'); final List<TxtResourceRecord> txt = await client .lookup<TxtResourceRecord>( ResourceRecordQuery.text(domainName), ) .toList(); String authCode = ''; if (txt.isNotEmpty) { authCode = _getAuthCode(txt.first.text); } results.add(MDnsVmServiceDiscoveryResult( domainName, srvRecord.port, authCode, ipAddress: ipAddress )); uniqueDomainNamesInResults.add(domainName); if (quitOnFind) { return results; } } // If applicationId is set and quitOnFind is true and no results matching // the applicationId were found but other results were found, throw an error. if (applicationId != null && quitOnFind && results.isEmpty && uniqueDomainNames.isNotEmpty) { String message = 'Did not find a Dart VM Service advertised for $applicationId'; if (deviceVmservicePort != null) { message += ' on port $deviceVmservicePort'; } throwToolExit('$message.'); } return results; } finally { client.stop(); } } String _getAuthCode(String txtRecord) { const String authCodePrefix = 'authCode='; final Iterable<String> matchingRecords = LineSplitter.split(txtRecord).where((String record) => record.startsWith(authCodePrefix)); if (matchingRecords.isEmpty) { return ''; } String authCode = matchingRecords.first.substring(authCodePrefix.length); // The Dart VM Service currently expects a trailing '/' as part of the // URI, otherwise an invalid authentication code response is given. if (!authCode.endsWith('/')) { authCode += '/'; } return authCode; } /// Gets Dart VM Service Uri for `flutter attach`. /// Executes an mDNS query and waits until a Dart VM Service is found. /// /// Differs from `getVMServiceUriForLaunch` because it can search for any available Dart VM Service. /// Since [applicationId] and [deviceVmservicePort] are optional, it can either look for any service /// or a specific service matching [applicationId]/[deviceVmservicePort]. /// It may find more than one service, which will throw an error listing the found services. Future<Uri?> getVMServiceUriForAttach( String? applicationId, Device device, { bool usesIpv6 = false, int? hostVmservicePort, int? deviceVmservicePort, bool isNetworkDevice = false, Duration timeout = const Duration(minutes: 10), }) async { final MDnsVmServiceDiscoveryResult? result = await queryForAttach( applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, ipv6: usesIpv6, isNetworkDevice: isNetworkDevice, timeout: timeout, ); return _handleResult( result, device, applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, hostVmservicePort: hostVmservicePort, usesIpv6: usesIpv6, isNetworkDevice: isNetworkDevice ); } /// Gets Dart VM Service Uri for `flutter run`. /// Executes an mDNS query and waits until the Dart VM Service service is found. /// /// Differs from `getVMServiceUriForAttach` because it only searches for a specific service. /// This is enforced by [applicationId] and [deviceVmservicePort] being required. Future<Uri?> getVMServiceUriForLaunch( String applicationId, Device device, { bool usesIpv6 = false, int? hostVmservicePort, required int deviceVmservicePort, bool isNetworkDevice = false, Duration timeout = const Duration(minutes: 10), }) async { final MDnsVmServiceDiscoveryResult? result = await queryForLaunch( applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, ipv6: usesIpv6, isNetworkDevice: isNetworkDevice, timeout: timeout, ); return _handleResult( result, device, applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, hostVmservicePort: hostVmservicePort, usesIpv6: usesIpv6, isNetworkDevice: isNetworkDevice ); } Future<Uri?> _handleResult( MDnsVmServiceDiscoveryResult? result, Device device, { String? applicationId, int? deviceVmservicePort, int? hostVmservicePort, bool usesIpv6 = false, bool isNetworkDevice = false, }) async { if (result == null) { await _checkForIPv4LinkLocal(device); return null; } final String host; final InternetAddress? ipAddress = result.ipAddress; if (isNetworkDevice && ipAddress != null) { host = ipAddress.address; } else { host = usesIpv6 ? InternetAddress.loopbackIPv6.address : InternetAddress.loopbackIPv4.address; } return buildVMServiceUri( device, host, result.port, hostVmservicePort, result.authCode, isNetworkDevice, ); } // If there's not an ipv4 link local address in `NetworkInterfaces.list`, // then request user interventions with a `printError()` if possible. Future<void> _checkForIPv4LinkLocal(Device device) async { _logger.printTrace( 'mDNS query failed. Checking for an interface with a ipv4 link local address.' ); final List<NetworkInterface> interfaces = await listNetworkInterfaces( includeLinkLocal: true, type: InternetAddressType.IPv4, ); if (_logger.isVerbose) { _logInterfaces(interfaces); } final bool hasIPv4LinkLocal = interfaces.any( (NetworkInterface interface) => interface.addresses.any( (InternetAddress address) => address.isLinkLocal, ), ); if (hasIPv4LinkLocal) { _logger.printTrace('An interface with an ipv4 link local address was found.'); return; } final TargetPlatform targetPlatform = await device.targetPlatform; switch (targetPlatform) { case TargetPlatform.ios: UsageEvent('ios-mdns', 'no-ipv4-link-local', flutterUsage: _flutterUsage).send(); _logger.printError( 'The mDNS query for an attached iOS device failed. It may ' 'be necessary to disable the "Personal Hotspot" on the device, and ' 'to ensure that the "Disable unless needed" setting is unchecked ' 'under System Preferences > Network > iPhone USB. ' 'See https://github.com/flutter/flutter/issues/46698 for details.' ); case TargetPlatform.android: case TargetPlatform.android_arm: case TargetPlatform.android_arm64: case TargetPlatform.android_x64: case TargetPlatform.android_x86: case TargetPlatform.darwin: case TargetPlatform.fuchsia_arm64: case TargetPlatform.fuchsia_x64: case TargetPlatform.linux_arm64: case TargetPlatform.linux_x64: case TargetPlatform.tester: case TargetPlatform.web_javascript: case TargetPlatform.windows_x64: _logger.printTrace('No interface with an ipv4 link local address was found.'); } } void _logInterfaces(List<NetworkInterface> interfaces) { for (final NetworkInterface interface in interfaces) { if (_logger.isVerbose) { _logger.printTrace('Found interface "${interface.name}":'); for (final InternetAddress address in interface.addresses) { final String linkLocal = address.isLinkLocal ? 'link local' : ''; _logger.printTrace('\tBound address: "${address.address}" $linkLocal'); } } } } } class MDnsVmServiceDiscoveryResult { MDnsVmServiceDiscoveryResult( this.domainName, this.port, this.authCode, { this.ipAddress }); final String domainName; final int port; final String authCode; final InternetAddress? ipAddress; } Future<Uri> buildVMServiceUri( Device device, String host, int devicePort, [ int? hostVmservicePort, String? authCode, bool isNetworkDevice = false, ]) async { String path = '/'; if (authCode != null) { path = authCode; } // Not having a trailing slash can cause problems in some situations. // Ensure that there's one present. if (!path.endsWith('/')) { path += '/'; } hostVmservicePort ??= 0; final int? actualHostPort; if (isNetworkDevice) { // When debugging with a network device, port forwarding is not required // so just use the device's port. actualHostPort = devicePort; } else { actualHostPort = hostVmservicePort == 0 ? await device.portForwarder?.forward(devicePort) : hostVmservicePort; } return Uri(scheme: 'http', host: host, port: actualHostPort, path: path); }