| // 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 '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 'device.dart'; |
| import 'reporting/reporting.dart'; |
| |
| /// A wrapper around [MDnsClient] to find a Dart observatory instance. |
| class MDnsObservatoryDiscovery { |
| /// Creates a new [MDnsObservatoryDiscovery] object. |
| /// |
| /// The [_client] parameter will be defaulted to a new [MDnsClient] if null. |
| /// The [applicationId] parameter may be null, and can be used to |
| /// automatically select which application to use if multiple are advertising |
| /// Dart observatory ports. |
| MDnsObservatoryDiscovery({ |
| MDnsClient? mdnsClient, |
| required Logger logger, |
| required Usage flutterUsage, |
| }): _client = mdnsClient ?? MDnsClient(), |
| _logger = logger, |
| _flutterUsage = flutterUsage; |
| |
| final MDnsClient _client; |
| final Logger _logger; |
| final Usage _flutterUsage; |
| |
| @visibleForTesting |
| static const String dartObservatoryName = '_dartobservatory._tcp.local'; |
| |
| static MDnsObservatoryDiscovery? get instance => context.get<MDnsObservatoryDiscovery>(); |
| |
| /// Executes an mDNS query for a Dart Observatory. |
| /// |
| /// 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. |
| /// |
| /// If it is not null, this method will find the port and authentication code |
| /// of the Dart Observatory for that application. If it cannot find a Dart |
| /// Observatory matching that application identifier, it will call |
| /// [throwToolExit]. |
| /// |
| /// If it is null and there are multiple ports available, the user will be |
| /// prompted with a list of available observatory ports and asked to select |
| /// one. |
| /// |
| /// If it is null and there is only one available instance of Observatory, |
| /// it will return that instance's information regardless of what application |
| /// the Observatory instance is for. |
| @visibleForTesting |
| Future<MDnsObservatoryDiscoveryResult?> query({String? applicationId, int? deviceVmservicePort}) async { |
| _logger.printTrace('Checking for advertised Dart observatories...'); |
| try { |
| await _client.start(); |
| final List<PtrResourceRecord> pointerRecords = await _client |
| .lookup<PtrResourceRecord>( |
| ResourceRecordQuery.serverPointer(dartObservatoryName), |
| ) |
| .toList(); |
| if (pointerRecords.isEmpty) { |
| _logger.printTrace('No pointer records found.'); |
| return null; |
| } |
| // We have no guarantee that we won't get multiple hits from the same |
| // service on this. |
| final Set<String> uniqueDomainNames = pointerRecords |
| .map<String>((PtrResourceRecord record) => record.domainName) |
| .toSet(); |
| |
| String? domainName; |
| if (applicationId != null) { |
| for (final String name in uniqueDomainNames) { |
| if (name.toLowerCase().startsWith(applicationId.toLowerCase())) { |
| domainName = name; |
| break; |
| } |
| } |
| if (domainName == null) { |
| throwToolExit('Did not find a observatory port advertised for $applicationId.'); |
| } |
| } else if (uniqueDomainNames.length > 1) { |
| final StringBuffer buffer = StringBuffer(); |
| buffer.writeln('There are multiple observatory ports available.'); |
| buffer.writeln('Rerun this command with one of the following passed in as the appId:'); |
| buffer.writeln(); |
| for (final String uniqueDomainName in uniqueDomainNames) { |
| buffer.writeln(' flutter attach --app-id ${uniqueDomainName.replaceAll('.$dartObservatoryName', '')}'); |
| } |
| throwToolExit(buffer.toString()); |
| } else { |
| domainName = pointerRecords[0].domainName; |
| } |
| _logger.printTrace('Checking for available port on $domainName'); |
| // Here, if we get more than one, it should just be a duplicate. |
| final List<SrvResourceRecord> srv = await _client |
| .lookup<SrvResourceRecord>( |
| ResourceRecordQuery.service(domainName), |
| ) |
| .toList(); |
| if (srv.isEmpty) { |
| return null; |
| } |
| if (srv.length > 1) { |
| _logger.printWarning('Unexpectedly found more than one observatory report for $domainName ' |
| '- using first one (${srv.first.port}).'); |
| } |
| _logger.printTrace('Checking for authentication code for $domainName'); |
| final List<TxtResourceRecord> txt = await _client |
| .lookup<TxtResourceRecord>( |
| ResourceRecordQuery.text(domainName), |
| ) |
| .toList(); |
| if (txt == null || txt.isEmpty) { |
| return MDnsObservatoryDiscoveryResult(srv.first.port, ''); |
| } |
| const String authCodePrefix = 'authCode='; |
| String? raw; |
| for (final String record in txt.first.text.split('\n')) { |
| if (record.startsWith(authCodePrefix)) { |
| raw = record; |
| break; |
| } |
| } |
| if (raw == null) { |
| return MDnsObservatoryDiscoveryResult(srv.first.port, ''); |
| } |
| String authCode = raw.substring(authCodePrefix.length); |
| // The Observatory currently expects a trailing '/' as part of the |
| // URI, otherwise an invalid authentication code response is given. |
| if (!authCode.endsWith('/')) { |
| authCode += '/'; |
| } |
| return MDnsObservatoryDiscoveryResult(srv.first.port, authCode); |
| } finally { |
| _client.stop(); |
| } |
| } |
| |
| Future<Uri?> getObservatoryUri(String? applicationId, Device device, { |
| bool usesIpv6 = false, |
| int? hostVmservicePort, |
| int? deviceVmservicePort, |
| }) async { |
| final MDnsObservatoryDiscoveryResult? result = await query( |
| applicationId: applicationId, |
| deviceVmservicePort: deviceVmservicePort, |
| ); |
| if (result == null) { |
| await _checkForIPv4LinkLocal(device); |
| return null; |
| } |
| |
| final String host = usesIpv6 |
| ? InternetAddress.loopbackIPv6.address |
| : InternetAddress.loopbackIPv4.address; |
| return buildObservatoryUri( |
| device, |
| host, |
| result.port, |
| hostVmservicePort, |
| result.authCode, |
| ); |
| } |
| |
| // 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.' |
| ); |
| break; |
| 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.'); |
| break; |
| } |
| } |
| |
| 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 MDnsObservatoryDiscoveryResult { |
| MDnsObservatoryDiscoveryResult(this.port, this.authCode); |
| final int port; |
| final String authCode; |
| } |
| |
| Future<Uri> buildObservatoryUri( |
| Device device, |
| String host, |
| int devicePort, [ |
| int? hostVmservicePort, |
| String? authCode, |
| ]) 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 = hostVmservicePort == 0 ? |
| await device.portForwarder?.forward(devicePort) : |
| hostVmservicePort; |
| return Uri(scheme: 'http', host: host, port: actualHostPort, path: path); |
| } |