mdns_discovery.dart 9.51 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8 9 10
// 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';
11
import 'base/logger.dart';
12
import 'build_info.dart';
13
import 'device.dart';
14
import 'reporting/reporting.dart';
15 16 17 18 19

/// A wrapper around [MDnsClient] to find a Dart observatory instance.
class MDnsObservatoryDiscovery {
  /// Creates a new [MDnsObservatoryDiscovery] object.
  ///
20
  /// The [_client] parameter will be defaulted to a new [MDnsClient] if null.
21 22 23
  /// The [applicationId] parameter may be null, and can be used to
  /// automatically select which application to use if multiple are advertising
  /// Dart observatory ports.
24
  MDnsObservatoryDiscovery({
25 26 27
    MDnsClient? mdnsClient,
    required Logger logger,
    required Usage flutterUsage,
28 29 30
  }): _client = mdnsClient ?? MDnsClient(),
      _logger = logger,
      _flutterUsage = flutterUsage;
31

32 33 34
  final MDnsClient _client;
  final Logger _logger;
  final Usage _flutterUsage;
35 36 37 38

  @visibleForTesting
  static const String dartObservatoryName = '_dartobservatory._tcp.local';

39
  static MDnsObservatoryDiscovery? get instance => context.get<MDnsObservatoryDiscovery>();
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58

  /// 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.
59
  @visibleForTesting
60
  Future<MDnsObservatoryDiscoveryResult?> query({String? applicationId, int? deviceVmservicePort}) async {
61
    _logger.printTrace('Checking for advertised Dart observatories...');
62
    try {
63 64
      await _client.start();
      final List<PtrResourceRecord> pointerRecords = await _client
65 66 67 68
        .lookup<PtrResourceRecord>(
          ResourceRecordQuery.serverPointer(dartObservatoryName),
        )
        .toList();
69
      if (pointerRecords.isEmpty) {
70
        _logger.printTrace('No pointer records found.');
71 72 73 74
        return null;
      }
      // We have no guarantee that we won't get multiple hits from the same
      // service on this.
75
      final Set<String> uniqueDomainNames = pointerRecords
76 77
        .map<String>((PtrResourceRecord record) => record.domainName)
        .toSet();
78

79
      String? domainName;
80
      if (applicationId != null) {
81
        for (final String name in uniqueDomainNames) {
82 83 84 85 86 87 88 89 90 91 92 93
          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:');
94
        buffer.writeln();
95
        for (final String uniqueDomainName in uniqueDomainNames) {
96 97 98 99 100 101
          buffer.writeln('  flutter attach --app-id ${uniqueDomainName.replaceAll('.$dartObservatoryName', '')}');
        }
        throwToolExit(buffer.toString());
      } else {
        domainName = pointerRecords[0].domainName;
      }
102
      _logger.printTrace('Checking for available port on $domainName');
103
      // Here, if we get more than one, it should just be a duplicate.
104
      final List<SrvResourceRecord> srv = await _client
105 106 107 108
        .lookup<SrvResourceRecord>(
          ResourceRecordQuery.service(domainName),
        )
        .toList();
109 110 111 112
      if (srv.isEmpty) {
        return null;
      }
      if (srv.length > 1) {
113
        _logger.printWarning('Unexpectedly found more than one observatory report for $domainName '
114 115
                   '- using first one (${srv.first.port}).');
      }
116 117
      _logger.printTrace('Checking for authentication code for $domainName');
      final List<TxtResourceRecord> txt = await _client
118 119 120
        .lookup<TxtResourceRecord>(
            ResourceRecordQuery.text(domainName),
        )
121
        .toList();
122 123 124 125
      if (txt == null || txt.isEmpty) {
        return MDnsObservatoryDiscoveryResult(srv.first.port, '');
      }
      const String authCodePrefix = 'authCode=';
126 127 128 129 130 131 132
      String? raw;
      for (final String record in txt.first.text.split('\n')) {
        if (record.startsWith(authCodePrefix)) {
          raw = record;
          break;
        }
      }
133 134 135 136 137 138 139 140
      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 += '/';
141 142 143
      }
      return MDnsObservatoryDiscoveryResult(srv.first.port, authCode);
    } finally {
144
      _client.stop();
145 146 147
    }
  }

148
  Future<Uri?> getObservatoryUri(String applicationId, Device device, {
149
    bool usesIpv6 = false,
150 151
    int? hostVmservicePort,
    int? deviceVmservicePort,
152
  }) async {
153
    final MDnsObservatoryDiscoveryResult? result = await query(
154 155 156
      applicationId: applicationId,
      deviceVmservicePort: deviceVmservicePort,
    );
157 158 159 160 161 162 163 164
    if (result == null) {
      await _checkForIPv4LinkLocal(device);
      return null;
    }

    final String host = usesIpv6
      ? InternetAddress.loopbackIPv6.address
      : InternetAddress.loopbackIPv4.address;
165
    return buildObservatoryUri(
166 167 168 169 170 171 172 173 174 175 176
      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 {
177
    _logger.printTrace(
178 179 180 181 182 183
      'mDNS query failed. Checking for an interface with a ipv4 link local address.'
    );
    final List<NetworkInterface> interfaces = await listNetworkInterfaces(
      includeLinkLocal: true,
      type: InternetAddressType.IPv4,
    );
184
    if (_logger.isVerbose) {
185 186 187 188 189 190 191 192
      _logInterfaces(interfaces);
    }
    final bool hasIPv4LinkLocal = interfaces.any(
      (NetworkInterface interface) => interface.addresses.any(
        (InternetAddress address) => address.isLinkLocal,
      ),
    );
    if (hasIPv4LinkLocal) {
193
      _logger.printTrace('An interface with an ipv4 link local address was found.');
194 195 196 197 198
      return;
    }
    final TargetPlatform targetPlatform = await device.targetPlatform;
    switch (targetPlatform) {
      case TargetPlatform.ios:
199 200
        UsageEvent('ios-mdns', 'no-ipv4-link-local', flutterUsage: _flutterUsage).send();
        _logger.printError(
201
          'The mDNS query for an attached iOS device failed. It may '
202 203
          'be necessary to disable the "Personal Hotspot" on the device, and '
          'to ensure that the "Disable unless needed" setting is unchecked '
204
          'under System Preferences > Network > iPhone USB. '
205 206 207
          'See https://github.com/flutter/flutter/issues/46698 for details.'
        );
        break;
208 209 210 211 212 213 214 215 216 217 218 219 220
      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:
221
        _logger.printTrace('No interface with an ipv4 link local address was found.');
222 223 224 225 226
        break;
    }
  }

  void _logInterfaces(List<NetworkInterface> interfaces) {
227
    for (final NetworkInterface interface in interfaces) {
228 229
      if (_logger.isVerbose) {
        _logger.printTrace('Found interface "${interface.name}":');
230
        for (final InternetAddress address in interface.addresses) {
231
          final String linkLocal = address.isLinkLocal ? 'link local' : '';
232
          _logger.printTrace('\tBound address: "${address.address}" $linkLocal');
233 234
        }
      }
235 236 237 238 239 240 241 242 243 244
    }
  }
}

class MDnsObservatoryDiscoveryResult {
  MDnsObservatoryDiscoveryResult(this.port, this.authCode);
  final int port;
  final String authCode;
}

245 246 247 248
Future<Uri> buildObservatoryUri(
  Device device,
  String host,
  int devicePort, [
249 250
  int? hostVmservicePort,
  String? authCode,
251
]) async {
252 253 254 255 256 257 258 259 260
  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 += '/';
  }
261
  hostVmservicePort ??= 0;
262 263
  final int? actualHostPort = hostVmservicePort == 0 ?
    await device.portForwarder?.forward(devicePort) :
264
    hostVmservicePort;
265
  return Uri(scheme: 'http', host: host, port: actualHostPort, path: path);
266
}