mdns_discovery.dart 9.08 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
// @dart = 2.8

7 8 9 10 11 12
import 'package:meta/meta.dart';
import 'package:multicast_dns/multicast_dns.dart';

import 'base/common.dart';
import 'base/context.dart';
import 'base/io.dart';
13
import 'base/logger.dart';
14
import 'build_info.dart';
15
import 'device.dart';
16
import 'reporting/reporting.dart';
17 18 19 20 21

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

34 35 36
  final MDnsClient _client;
  final Logger _logger;
  final Usage _flutterUsage;
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60

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

      String domainName;
      if (applicationId != null) {
84
        for (final String name in uniqueDomainNames) {
85 86 87 88 89 90 91 92 93 94 95 96 97
          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('');
98
        for (final String uniqueDomainName in uniqueDomainNames) {
99 100 101 102 103 104
          buffer.writeln('  flutter attach --app-id ${uniqueDomainName.replaceAll('.$dartObservatoryName', '')}');
        }
        throwToolExit(buffer.toString());
      } else {
        domainName = pointerRecords[0].domainName;
      }
105
      _logger.printTrace('Checking for available port on $domainName');
106
      // Here, if we get more than one, it should just be a duplicate.
107
      final List<SrvResourceRecord> srv = await _client
108 109 110 111
        .lookup<SrvResourceRecord>(
          ResourceRecordQuery.service(domainName),
        )
        .toList();
112 113 114 115
      if (srv.isEmpty) {
        return null;
      }
      if (srv.length > 1) {
116
        _logger.printError('Unexpectedly found more than one observatory report for $domainName '
117 118
                   '- using first one (${srv.first.port}).');
      }
119 120
      _logger.printTrace('Checking for authentication code for $domainName');
      final List<TxtResourceRecord> txt = await _client
121 122 123 124 125 126 127 128
        .lookup<TxtResourceRecord>(
            ResourceRecordQuery.text(domainName),
        )
        ?.toList();
      if (txt == null || txt.isEmpty) {
        return MDnsObservatoryDiscoveryResult(srv.first.port, '');
      }
      const String authCodePrefix = 'authCode=';
129 130 131 132 133 134 135 136 137 138 139 140
      final String raw = txt.first.text.split('\n').firstWhere(
        (String s) => s.startsWith(authCodePrefix),
        orElse: () => null,
      );
      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 149 150 151 152 153 154 155 156
  Future<Uri> getObservatoryUri(String applicationId, Device device, {
    bool usesIpv6 = false,
    int hostVmservicePort,
    int deviceVmservicePort,
  }) async {
    final MDnsObservatoryDiscoveryResult result = await query(
      applicationId: applicationId,
      deviceVmservicePort: deviceVmservicePort,
    );
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
    if (result == null) {
      await _checkForIPv4LinkLocal(device);
      return null;
    }

    final String host = usesIpv6
      ? InternetAddress.loopbackIPv6.address
      : InternetAddress.loopbackIPv4.address;
    return await 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 {
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 208
          'See https://github.com/flutter/flutter/issues/46698 for details.'
        );
        break;
      default:
209
        _logger.printTrace('No interface with an ipv4 link local address was found.');
210 211 212 213 214
        break;
    }
  }

  void _logInterfaces(List<NetworkInterface> interfaces) {
215
    for (final NetworkInterface interface in interfaces) {
216 217
      if (_logger.isVerbose) {
        _logger.printTrace('Found interface "${interface.name}":');
218
        for (final InternetAddress address in interface.addresses) {
219
          final String linkLocal = address.isLinkLocal ? 'link local' : '';
220
          _logger.printTrace('\tBound address: "${address.address}" $linkLocal');
221 222
        }
      }
223 224 225 226 227 228 229 230 231 232
    }
  }
}

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

233 234 235 236
Future<Uri> buildObservatoryUri(
  Device device,
  String host,
  int devicePort, [
237
  int hostVmservicePort,
238 239
  String authCode,
]) async {
240 241 242 243 244 245 246 247 248
  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 += '/';
  }
249 250 251 252
  hostVmservicePort ??= 0;
  final int actualHostPort = hostVmservicePort == 0 ?
    await device.portForwarder.forward(devicePort) :
    hostVmservicePort;
253
  return Uri(scheme: 'http', host: host, port: actualHostPort, path: path);
254
}