mdns_discovery.dart 9.16 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 11 12
// 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';
13
import 'build_info.dart';
14
import 'device.dart';
15
import 'globals.dart' as globals;
16
import 'reporting/reporting.dart';
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54

/// 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})
    : client = mdnsClient ?? MDnsClient();

  /// The [MDnsClient] used to do a lookup.
  final MDnsClient client;

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

      String domainName;
      if (applicationId != null) {
77
        for (final String name in uniqueDomainNames) {
78 79 80 81 82 83 84 85 86 87 88 89 90
          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('');
91
        for (final String uniqueDomainName in uniqueDomainNames) {
92 93 94 95 96 97
          buffer.writeln('  flutter attach --app-id ${uniqueDomainName.replaceAll('.$dartObservatoryName', '')}');
        }
        throwToolExit(buffer.toString());
      } else {
        domainName = pointerRecords[0].domainName;
      }
98
      globals.printTrace('Checking for available port on $domainName');
99 100
      // Here, if we get more than one, it should just be a duplicate.
      final List<SrvResourceRecord> srv = await client
101 102 103 104
        .lookup<SrvResourceRecord>(
          ResourceRecordQuery.service(domainName),
        )
        .toList();
105 106 107 108
      if (srv.isEmpty) {
        return null;
      }
      if (srv.length > 1) {
109
        globals.printError('Unexpectedly found more than one observatory report for $domainName '
110 111
                   '- using first one (${srv.first.port}).');
      }
112
      globals.printTrace('Checking for authentication code for $domainName');
113 114 115 116 117 118 119 120 121
      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=';
122 123 124 125 126 127 128 129 130 131 132 133
      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 += '/';
134 135
      }
      return MDnsObservatoryDiscoveryResult(srv.first.port, authCode);
136 137 138 139 140
    } on OSError catch (e) {
      // OSError is neither an Error nor and Exception, so we wrap it in a
      // SocketException and rethrow.
      // See: https://github.com/dart-lang/sdk/issues/40934
      throw SocketException('mdns query failed', osError: e);
141 142 143 144 145
    } finally {
      client.stop();
    }
  }

146 147 148 149 150 151 152 153 154
  Future<Uri> getObservatoryUri(String applicationId, Device device, {
    bool usesIpv6 = false,
    int hostVmservicePort,
    int deviceVmservicePort,
  }) async {
    final MDnsObservatoryDiscoveryResult result = await query(
      applicationId: applicationId,
      deviceVmservicePort: deviceVmservicePort,
    );
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174
    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 {
175
    globals.printTrace(
176 177 178 179 180 181
      'mDNS query failed. Checking for an interface with a ipv4 link local address.'
    );
    final List<NetworkInterface> interfaces = await listNetworkInterfaces(
      includeLinkLocal: true,
      type: InternetAddressType.IPv4,
    );
182
    if (globals.logger.isVerbose) {
183 184 185 186 187 188 189 190
      _logInterfaces(interfaces);
    }
    final bool hasIPv4LinkLocal = interfaces.any(
      (NetworkInterface interface) => interface.addresses.any(
        (InternetAddress address) => address.isLinkLocal,
      ),
    );
    if (hasIPv4LinkLocal) {
191
      globals.printTrace('An interface with an ipv4 link local address was found.');
192 193 194 195 196
      return;
    }
    final TargetPlatform targetPlatform = await device.targetPlatform;
    switch (targetPlatform) {
      case TargetPlatform.ios:
197
        UsageEvent('ios-mdns', 'no-ipv4-link-local', flutterUsage: globals.flutterUsage).send();
198
        globals.printError(
199
          'The mDNS query for an attached iOS device failed. It may '
200 201
          'be necessary to disable the "Personal Hotspot" on the device, and '
          'to ensure that the "Disable unless needed" setting is unchecked '
202
          'under System Preferences > Network > iPhone USB. '
203 204 205 206
          'See https://github.com/flutter/flutter/issues/46698 for details.'
        );
        break;
      default:
207
        globals.printTrace('No interface with an ipv4 link local address was found.');
208 209 210 211 212
        break;
    }
  }

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

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

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