flutter/packages/flutter_tools/lib/src/mdns_discovery.dart
Victoria Ashworth e841350bfc
Don't throw on error for mDNS when searching for Dart VML url on core devices (#167135)
When we discover the Dart VM for iOS Core Devices (iOS 17+), we use both
the device logs and mDNS simultaneously. Since mDNS is having issues
with macOS 15, don't throw if mDNS errors - this will allow the Dart VM
to have a chance to be found by the device logs instead.

Improving experience for
https://github.com/flutter/flutter/issues/150131.

Also should unblock https://github.com/flutter/flutter/issues/166843.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2025-04-16 22:46:13 +00:00

667 lines
24 KiB
Dart

// 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 'package:unified_analytics/unified_analytics.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 'globals.dart' as globals;
String _missingLocalNetworkPermissionsInstructions(String err) => '''
Flutter could not access the local network.
Please ensure your IDE or terminal app has permission to access devices on the local network. This allows Flutter to connect to the Dart VM.
You can grant this permission in System Settings > Privacy & Security > Local Network.
$err
''';
/// 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 Analytics analytics,
}) : _client = mdnsClient ?? MDnsClient(),
_preliminaryClient = preliminaryMDnsClient,
_logger = logger,
_analytics = analytics;
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 Analytics _analytics;
@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 [useDeviceIPAsHost] 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 useDeviceIPAsHost = false,
Duration timeout = const Duration(minutes: 10),
bool throwOnMissingLocalNetworkPermissionsError = true,
}) 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,
useDeviceIPAsHost: useDeviceIPAsHost,
timeout: const Duration(seconds: 5),
throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError,
);
if (results.isEmpty) {
return firstMatchingVmService(
_client,
applicationId: applicationId,
deviceVmservicePort: deviceVmservicePort,
ipv6: ipv6,
useDeviceIPAsHost: useDeviceIPAsHost,
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 either [deviceVmservicePort] or [deviceName] 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 [useDeviceIPAsHost] 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]/[deviceName] cannot be found before the [timeout]
/// is reached, it will call [throwToolExit].
@visibleForTesting
Future<MDnsVmServiceDiscoveryResult?> queryForLaunch({
required String applicationId,
int? deviceVmservicePort,
String? deviceName,
bool ipv6 = false,
bool useDeviceIPAsHost = false,
Duration timeout = const Duration(minutes: 10),
bool throwOnMissingLocalNetworkPermissionsError = true,
}) async {
// Either the device port or the device name must be provided.
assert(deviceVmservicePort != null || deviceName != null);
// Query for a specific application matching on either device port or device name.
return firstMatchingVmService(
_client,
applicationId: applicationId,
deviceVmservicePort: deviceVmservicePort,
deviceName: deviceName,
ipv6: ipv6,
useDeviceIPAsHost: useDeviceIPAsHost,
timeout: timeout,
throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError,
);
}
/// 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,
String? deviceName,
bool ipv6 = false,
bool useDeviceIPAsHost = false,
Duration timeout = const Duration(minutes: 10),
bool throwOnMissingLocalNetworkPermissionsError = true,
}) async {
final List<MDnsVmServiceDiscoveryResult> results = await _pollingVmService(
client,
applicationId: applicationId,
deviceVmServicePort: deviceVmservicePort,
deviceName: deviceName,
ipv6: ipv6,
useDeviceIPAsHost: useDeviceIPAsHost,
timeout: timeout,
quitOnFind: true,
throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError,
);
if (results.isEmpty) {
return null;
}
return results.first;
}
Future<List<MDnsVmServiceDiscoveryResult>> _pollingVmService(
MDnsClient client, {
String? applicationId,
int? deviceVmServicePort,
String? deviceName,
bool ipv6 = false,
bool useDeviceIPAsHost = false,
required Duration timeout,
bool quitOnFind = false,
bool throwOnMissingLocalNetworkPermissionsError = true,
}) async {
// macOS blocks mDNS unless the app has Local Network permissions.
// Since the mDNS client does not handle errors from the socket's stream,
// socket exceptions are routed to the current zone. Create an error zone to
// catch the socket exception.
// See: https://github.com/flutter/flutter/issues/150131
final Completer<List<MDnsVmServiceDiscoveryResult>> completer =
Completer<List<MDnsVmServiceDiscoveryResult>>();
unawaited(
runZonedGuarded(
() async {
final List<MDnsVmServiceDiscoveryResult> results = await _doPollingVmService(
client,
applicationId: applicationId,
deviceVmServicePort: deviceVmServicePort,
deviceName: deviceName,
ipv6: ipv6,
useDeviceIPAsHost: useDeviceIPAsHost,
timeout: timeout,
quitOnFind: quitOnFind,
);
if (!completer.isCompleted) {
completer.complete(results);
}
},
(Object error, StackTrace stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
},
),
);
try {
return await completer.future;
} on SocketException catch (e, stackTrace) {
if (!globals.platform.isMacOS) {
rethrow;
}
_logger.printTrace(stackTrace.toString());
if (throwOnMissingLocalNetworkPermissionsError) {
throwToolExit(_missingLocalNetworkPermissionsInstructions(e.toString()));
} else {
_logger.printError(_missingLocalNetworkPermissionsInstructions(e.toString()));
return <MDnsVmServiceDiscoveryResult>[];
}
}
}
Future<List<MDnsVmServiceDiscoveryResult>> _doPollingVmService(
MDnsClient client, {
String? applicationId,
int? deviceVmServicePort,
String? deviceName,
bool ipv6 = false,
bool useDeviceIPAsHost = 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;
}
// If deviceName is set, only use records that match it
if (deviceName != null && !deviceNameMatchesTargetName(deviceName, srvRecord.target)) {
continue;
}
// Get the IP address of the device if using the IP as the host.
InternetAddress? ipAddress;
if (useDeviceIPAsHost) {
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();
}
}
@visibleForTesting
bool deviceNameMatchesTargetName(String deviceName, String targetName) {
// Remove `.local` from the name along with any non-word, non-digit characters.
final RegExp cleanedNameRegex = RegExp(r'\.local|\W');
final String cleanedDeviceName = deviceName.trim().toLowerCase().replaceAll(
cleanedNameRegex,
'',
);
final String cleanedTargetName = targetName.toLowerCase().replaceAll(cleanedNameRegex, '');
return cleanedDeviceName == cleanedTargetName;
}
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.
///
/// When [useDeviceIPAsHost] is true, it will use the device's IP as the
/// host and will not forward the port.
///
/// 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 useDeviceIPAsHost = false,
Duration timeout = const Duration(minutes: 10),
}) async {
final MDnsVmServiceDiscoveryResult? result = await queryForAttach(
applicationId: applicationId,
deviceVmservicePort: deviceVmservicePort,
ipv6: usesIpv6,
useDeviceIPAsHost: useDeviceIPAsHost,
timeout: timeout,
);
return _handleResult(
result,
device,
applicationId: applicationId,
deviceVmservicePort: deviceVmservicePort,
hostVmservicePort: hostVmservicePort,
usesIpv6: usesIpv6,
useDeviceIPAsHost: useDeviceIPAsHost,
);
}
/// Gets Dart VM Service Uri for `flutter run`.
/// Executes an mDNS query and waits until the Dart VM Service service is found.
///
/// When [useDeviceIPAsHost] is true, it will use the device's IP as the
/// host and will not forward the port.
///
/// Differs from [getVMServiceUriForAttach] because it only searches for a specific service.
/// This is enforced by [applicationId] being required and using either the
/// [deviceVmservicePort] or the [device]'s name to query.
Future<Uri?> getVMServiceUriForLaunch(
String applicationId,
Device device, {
bool usesIpv6 = false,
int? hostVmservicePort,
int? deviceVmservicePort,
bool useDeviceIPAsHost = false,
Duration timeout = const Duration(minutes: 10),
bool throwOnMissingLocalNetworkPermissionsError = true,
}) async {
final MDnsVmServiceDiscoveryResult? result = await queryForLaunch(
applicationId: applicationId,
deviceVmservicePort: deviceVmservicePort,
deviceName: deviceVmservicePort == null ? device.name : null,
ipv6: usesIpv6,
useDeviceIPAsHost: useDeviceIPAsHost,
timeout: timeout,
throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError,
);
return _handleResult(
result,
device,
applicationId: applicationId,
deviceVmservicePort: deviceVmservicePort,
hostVmservicePort: hostVmservicePort,
usesIpv6: usesIpv6,
useDeviceIPAsHost: useDeviceIPAsHost,
);
}
Future<Uri?> _handleResult(
MDnsVmServiceDiscoveryResult? result,
Device device, {
String? applicationId,
int? deviceVmservicePort,
int? hostVmservicePort,
bool usesIpv6 = false,
bool useDeviceIPAsHost = false,
}) async {
if (result == null) {
await _checkForIPv4LinkLocal(device);
return null;
}
final String host;
final InternetAddress? ipAddress = result.ipAddress;
if (useDeviceIPAsHost && ipAddress != null) {
host = ipAddress.address;
} else {
host = usesIpv6 ? InternetAddress.loopbackIPv6.address : InternetAddress.loopbackIPv4.address;
}
return buildVMServiceUri(
device,
host,
result.port,
hostVmservicePort,
result.authCode,
useDeviceIPAsHost,
);
}
// 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:
_analytics.send(
Event.appleUsageEvent(workflow: 'ios-mdns', parameter: 'no-ipv4-link-local'),
);
_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:
case TargetPlatform.windows_arm64:
_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 useDeviceIPAsHost = 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 (useDeviceIPAsHost) {
// When using the device's IP as the host, 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);
}