• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2018 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6
7import 'package:multicast_dns/multicast_dns.dart';
8
9import '../artifacts.dart';
10import '../base/common.dart';
11import '../base/context.dart';
12import '../base/file_system.dart';
13import '../base/io.dart';
14import '../base/utils.dart';
15import '../cache.dart';
16import '../commands/daemon.dart';
17import '../compile.dart';
18import '../device.dart';
19import '../fuchsia/fuchsia_device.dart';
20import '../globals.dart';
21import '../ios/devices.dart';
22import '../ios/simulators.dart';
23import '../project.dart';
24import '../protocol_discovery.dart';
25import '../resident_runner.dart';
26import '../run_cold.dart';
27import '../run_hot.dart';
28import '../runner/flutter_command.dart';
29
30/// A Flutter-command that attaches to applications that have been launched
31/// without `flutter run`.
32///
33/// With an application already running, a HotRunner can be attached to it
34/// with:
35/// ```
36/// $ flutter attach --debug-uri http://127.0.0.1:12345/QqL7EFEDNG0=/
37/// ```
38///
39/// If `--disable-service-auth-codes` was provided to the application at startup
40/// time, a HotRunner can be attached with just a port:
41/// ```
42/// $ flutter attach --debug-port 12345
43/// ```
44///
45/// Alternatively, the attach command can start listening and scan for new
46/// programs that become active:
47/// ```
48/// $ flutter attach
49/// ```
50/// As soon as a new observatory is detected the command attaches to it and
51/// enables hot reloading.
52///
53/// To attach to a flutter mod running on a fuchsia device, `--module` must
54/// also be provided.
55class AttachCommand extends FlutterCommand {
56  AttachCommand({bool verboseHelp = false, this.hotRunnerFactory}) {
57    addBuildModeFlags(defaultToRelease: false);
58    usesIsolateFilterOption(hide: !verboseHelp);
59    usesTargetOption();
60    usesPortOptions();
61    usesIpv6Flag();
62    usesFilesystemOptions(hide: !verboseHelp);
63    usesFuchsiaOptions(hide: !verboseHelp);
64    argParser
65      ..addOption(
66        'debug-port',
67        hide: !verboseHelp,
68        help: 'Device port where the observatory is listening. Requires '
69        '--disable-service-auth-codes to also be provided to the Flutter '
70        'application at launch, otherwise this command will fail to connect to '
71        'the application. In general, --debug-uri should be used instead.',
72      )..addOption(
73        'debug-uri',
74        help: 'The URI at which the observatory is listening.',
75      )..addOption(
76        'app-id',
77        help: 'The package name (Android) or bundle identifier (iOS) for the application. '
78              'This can be specified to avoid being prompted if multiple observatory ports '
79              'are advertised.\n'
80              'If you have multiple devices or emulators running, you should include the '
81              'device hostname as well, e.g. "com.example.myApp@my-iphone".\n'
82              'This parameter is case-insensitive.',
83      )..addOption(
84        'pid-file',
85        help: 'Specify a file to write the process id to. '
86              'You can send SIGUSR1 to trigger a hot reload '
87              'and SIGUSR2 to trigger a hot restart.',
88      )..addOption(
89        'project-root',
90        hide: !verboseHelp,
91        help: 'Normally used only in run target',
92      )..addFlag('machine',
93        hide: !verboseHelp,
94        negatable: false,
95        help: 'Handle machine structured JSON command input and provide output '
96              'and progress in machine friendly format.',
97      );
98    usesTrackWidgetCreation(verboseHelp: verboseHelp);
99    hotRunnerFactory ??= HotRunnerFactory();
100  }
101
102  HotRunnerFactory hotRunnerFactory;
103
104  @override
105  final String name = 'attach';
106
107  @override
108  final String description = 'Attach to a running application.';
109
110  int get debugPort {
111    if (argResults['debug-port'] == null)
112      return null;
113    try {
114      return int.parse(argResults['debug-port']);
115    } catch (error) {
116      throwToolExit('Invalid port for `--debug-port`: $error');
117    }
118    return null;
119  }
120
121  Uri get debugUri {
122    if (argResults['debug-uri'] == null) {
123      return null;
124    }
125    final Uri uri = Uri.parse(argResults['debug-uri']);
126    if (!uri.hasPort) {
127      throwToolExit('Port not specified for `--debug-uri`: $uri');
128    }
129    return uri;
130  }
131
132  String get appId {
133    return argResults['app-id'];
134  }
135
136  @override
137  Future<void> validateCommand() async {
138    await super.validateCommand();
139    if (await findTargetDevice() == null)
140      throwToolExit(null);
141    debugPort;
142    if (debugPort == null && debugUri == null && argResults.wasParsed(FlutterCommand.ipv6Flag)) {
143      throwToolExit(
144        'When the --debug-port or --debug-uri is unknown, this command determines '
145        'the value of --ipv6 on its own.',
146      );
147    }
148    if (debugPort == null && debugUri == null && argResults.wasParsed(FlutterCommand.observatoryPortOption)) {
149      throwToolExit(
150        'When the --debug-port or --debug-uri is unknown, this command does not use '
151        'the value of --observatory-port.',
152      );
153    }
154    if (debugPort != null && debugUri != null) {
155      throwToolExit(
156        'Either --debugPort or --debugUri can be provided, not both.');
157    }
158  }
159
160  @override
161  Future<FlutterCommandResult> runCommand() async {
162    Cache.releaseLockEarly();
163
164    await _validateArguments();
165
166    writePidFile(argResults['pid-file']);
167
168    final Device device = await findTargetDevice();
169
170    final Artifacts artifacts = device.artifactOverrides ?? Artifacts.instance;
171    await context.run<void>(
172      body: () => _attachToDevice(device),
173      overrides: <Type, Generator>{
174        Artifacts: () => artifacts,
175    });
176
177    return null;
178  }
179
180  Future<void> _attachToDevice(Device device) async {
181    final FlutterProject flutterProject = FlutterProject.current();
182    Future<int> getDevicePort() async {
183      if (debugPort != null) {
184        return debugPort;
185      }
186      // This call takes a non-trivial amount of time, and only iOS devices and
187      // simulators support it.
188      // If/when we do this on Android or other platforms, we can update it here.
189      if (device is IOSDevice || device is IOSSimulator) {
190      }
191      return null;
192    }
193    final int devicePort = await getDevicePort();
194
195    final Daemon daemon = argResults['machine']
196      ? Daemon(stdinCommandStream, stdoutCommandResponse,
197            notifyingLogger: NotifyingLogger(), logToStdout: true)
198      : null;
199
200    Uri observatoryUri;
201    bool usesIpv6 = ipv6;
202    final String ipv6Loopback = InternetAddress.loopbackIPv6.address;
203    final String ipv4Loopback = InternetAddress.loopbackIPv4.address;
204    final String hostname = usesIpv6 ? ipv6Loopback : ipv4Loopback;
205
206    bool attachLogger = false;
207    if (devicePort == null  && debugUri == null) {
208      if (device is FuchsiaDevice) {
209        attachLogger = true;
210        final String module = argResults['module'];
211        if (module == null)
212          throwToolExit('\'--module\' is required for attaching to a Fuchsia device');
213        usesIpv6 = device.ipv6;
214        FuchsiaIsolateDiscoveryProtocol isolateDiscoveryProtocol;
215        try {
216          isolateDiscoveryProtocol = device.getIsolateDiscoveryProtocol(module);
217          observatoryUri = await isolateDiscoveryProtocol.uri;
218          printStatus('Done.'); // FYI, this message is used as a sentinel in tests.
219        } catch (_) {
220          isolateDiscoveryProtocol?.dispose();
221          final List<ForwardedPort> ports = device.portForwarder.forwardedPorts.toList();
222          for (ForwardedPort port in ports) {
223            await device.portForwarder.unforward(port);
224          }
225          rethrow;
226        }
227      } else if ((device is IOSDevice) || (device is IOSSimulator)) {
228        final MDnsObservatoryDiscoveryResult result = await MDnsObservatoryDiscovery().query(applicationId: appId);
229        if (result != null) {
230          observatoryUri = await _buildObservatoryUri(device, hostname, result.port, result.authCode);
231        }
232      }
233      // If MDNS discovery fails or we're not on iOS, fallback to ProtocolDiscovery.
234      if (observatoryUri == null) {
235        ProtocolDiscovery observatoryDiscovery;
236        try {
237          observatoryDiscovery = ProtocolDiscovery.observatory(
238            device.getLogReader(),
239            portForwarder: device.portForwarder,
240          );
241          printStatus('Waiting for a connection from Flutter on ${device.name}...');
242          observatoryUri = await observatoryDiscovery.uri;
243          // Determine ipv6 status from the scanned logs.
244          usesIpv6 = observatoryDiscovery.ipv6;
245          printStatus('Done.'); // FYI, this message is used as a sentinel in tests.
246        } catch (error) {
247          throwToolExit('Failed to establish a debug connection with ${device.name}: $error');
248        } finally {
249          await observatoryDiscovery?.cancel();
250        }
251      }
252    } else {
253      observatoryUri = await _buildObservatoryUri(device,
254          debugUri?.host ?? hostname, devicePort ?? debugUri.port, debugUri?.path);
255    }
256    try {
257      final bool useHot = getBuildInfo().isDebug;
258      final FlutterDevice flutterDevice = await FlutterDevice.create(
259        device,
260        flutterProject: flutterProject,
261        trackWidgetCreation: argResults['track-widget-creation'],
262        fileSystemRoots: argResults['filesystem-root'],
263        fileSystemScheme: argResults['filesystem-scheme'],
264        viewFilter: argResults['isolate-filter'],
265        target: argResults['target'],
266        targetModel: TargetModel(argResults['target-model']),
267        buildMode: getBuildMode(),
268      );
269      flutterDevice.observatoryUris = <Uri>[ observatoryUri ];
270      final List<FlutterDevice> flutterDevices =  <FlutterDevice>[flutterDevice];
271      final DebuggingOptions debuggingOptions = DebuggingOptions.enabled(getBuildInfo());
272      final ResidentRunner runner = useHot ?
273          hotRunnerFactory.build(
274            flutterDevices,
275            target: targetFile,
276            debuggingOptions: debuggingOptions,
277            packagesFilePath: globalResults['packages'],
278            usesTerminalUi: daemon == null,
279            projectRootPath: argResults['project-root'],
280            dillOutputPath: argResults['output-dill'],
281            ipv6: usesIpv6,
282            flutterProject: flutterProject,
283          )
284        : ColdRunner(
285            flutterDevices,
286            target: targetFile,
287            debuggingOptions: debuggingOptions,
288            ipv6: usesIpv6,
289          );
290      if (attachLogger) {
291        flutterDevice.startEchoingDeviceLog();
292      }
293
294      int result;
295      if (daemon != null) {
296        AppInstance app;
297        try {
298          app = await daemon.appDomain.launch(
299            runner,
300            runner.attach,
301            device,
302            null,
303            true,
304            fs.currentDirectory,
305            LaunchMode.attach,
306          );
307        } catch (error) {
308          throwToolExit(error.toString());
309        }
310        result = await app.runner.waitForAppToFinish();
311        assert(result != null);
312      } else {
313        final Completer<void> onAppStart = Completer<void>.sync();
314        unawaited(onAppStart.future.whenComplete(() {
315          TerminalHandler(runner)
316            ..setupTerminal()
317            ..registerSignalHandlers();
318        }));
319        result = await runner.attach(
320          appStartedCompleter: onAppStart,
321        );
322        assert(result != null);
323      }
324      if (result != 0) {
325        throwToolExit(null, exitCode: result);
326      }
327    } finally {
328      final List<ForwardedPort> ports = device.portForwarder.forwardedPorts.toList();
329      for (ForwardedPort port in ports) {
330        await device.portForwarder.unforward(port);
331      }
332    }
333  }
334
335  Future<void> _validateArguments() async { }
336
337  Future<Uri> _buildObservatoryUri(Device device,
338      String host, int devicePort, [String authCode]) async {
339    String path = '/';
340    if (authCode != null) {
341      path = authCode;
342    }
343    // Not having a trailing slash can cause problems in some situations.
344    // Ensure that there's one present.
345    if (!path.endsWith('/')) {
346      path += '/';
347    }
348    final int localPort = observatoryPort
349        ?? await device.portForwarder.forward(devicePort);
350    return Uri(scheme: 'http', host: host, port: localPort, path: path);
351  }
352}
353
354class HotRunnerFactory {
355  HotRunner build(
356    List<FlutterDevice> devices, {
357    String target,
358    DebuggingOptions debuggingOptions,
359    bool usesTerminalUi = true,
360    bool benchmarkMode = false,
361    File applicationBinary,
362    bool hostIsIde = false,
363    String projectRootPath,
364    String packagesFilePath,
365    String dillOutputPath,
366    bool stayResident = true,
367    bool ipv6 = false,
368    FlutterProject flutterProject,
369  }) => HotRunner(
370    devices,
371    target: target,
372    debuggingOptions: debuggingOptions,
373    usesTerminalUi: usesTerminalUi,
374    benchmarkMode: benchmarkMode,
375    applicationBinary: applicationBinary,
376    hostIsIde: hostIsIde,
377    projectRootPath: projectRootPath,
378    packagesFilePath: packagesFilePath,
379    dillOutputPath: dillOutputPath,
380    stayResident: stayResident,
381    ipv6: ipv6,
382  );
383}
384
385class MDnsObservatoryDiscoveryResult {
386  MDnsObservatoryDiscoveryResult(this.port, this.authCode);
387  final int port;
388  final String authCode;
389}
390
391/// A wrapper around [MDnsClient] to find a Dart observatory instance.
392class MDnsObservatoryDiscovery {
393  /// Creates a new [MDnsObservatoryDiscovery] object.
394  ///
395  /// The [client] parameter will be defaulted to a new [MDnsClient] if null.
396  /// The [applicationId] parameter may be null, and can be used to
397  /// automatically select which application to use if multiple are advertising
398  /// Dart observatory ports.
399  MDnsObservatoryDiscovery({MDnsClient mdnsClient})
400    : client = mdnsClient ?? MDnsClient();
401
402  /// The [MDnsClient] used to do a lookup.
403  final MDnsClient client;
404
405  static const String dartObservatoryName = '_dartobservatory._tcp.local';
406
407  /// Executes an mDNS query for a Dart Observatory.
408  ///
409  /// The [applicationId] parameter may be used to specify which application
410  /// to find.  For Android, it refers to the package name; on iOS, it refers to
411  /// the bundle ID.
412  ///
413  /// If it is not null, this method will find the port and authentication code
414  /// of the Dart Observatory for that application. If it cannot find a Dart
415  /// Observatory matching that application identifier, it will call
416  /// [throwToolExit].
417  ///
418  /// If it is null and there are multiple ports available, the user will be
419  /// prompted with a list of available observatory ports and asked to select
420  /// one.
421  ///
422  /// If it is null and there is only one available instance of Observatory,
423  /// it will return that instance's information regardless of what application
424  /// the Observatory instance is for.
425  Future<MDnsObservatoryDiscoveryResult> query({String applicationId}) async {
426    printStatus('Checking for advertised Dart observatories...');
427    try {
428      await client.start();
429      final List<PtrResourceRecord> pointerRecords = await client
430          .lookup<PtrResourceRecord>(
431            ResourceRecordQuery.serverPointer(dartObservatoryName),
432          )
433          .toList();
434      if (pointerRecords.isEmpty) {
435        return null;
436      }
437      // We have no guarantee that we won't get multiple hits from the same
438      // service on this.
439      final List<String> uniqueDomainNames = pointerRecords
440          .map<String>((PtrResourceRecord record) => record.domainName)
441          .toSet()
442          .toList();
443
444      String domainName;
445      if (applicationId != null) {
446        for (String name in uniqueDomainNames) {
447          if (name.toLowerCase().startsWith(applicationId.toLowerCase())) {
448            domainName = name;
449            break;
450          }
451        }
452        if (domainName == null) {
453          throwToolExit('Did not find a observatory port advertised for $applicationId.');
454        }
455      } else if (uniqueDomainNames.length > 1) {
456        final StringBuffer buffer = StringBuffer();
457        buffer.writeln('There are multiple observatory ports available.');
458        buffer.writeln('Rerun this command with one of the following passed in as the appId:');
459        buffer.writeln('');
460         for (final String uniqueDomainName in uniqueDomainNames) {
461          buffer.writeln('  flutter attach --app-id ${uniqueDomainName.replaceAll('.$dartObservatoryName', '')}');
462        }
463        throwToolExit(buffer.toString());
464      } else {
465        domainName = pointerRecords[0].domainName;
466      }
467      printStatus('Checking for available port on $domainName');
468      // Here, if we get more than one, it should just be a duplicate.
469      final List<SrvResourceRecord> srv = await client
470          .lookup<SrvResourceRecord>(
471            ResourceRecordQuery.service(domainName),
472          )
473          .toList();
474      if (srv.isEmpty) {
475        return null;
476      }
477      if (srv.length > 1) {
478        printError('Unexpectedly found more than one observatory report for $domainName '
479                   '- using first one (${srv.first.port}).');
480      }
481      printStatus('Checking for authentication code for $domainName');
482      final List<TxtResourceRecord> txt = await client
483        .lookup<TxtResourceRecord>(
484            ResourceRecordQuery.text(domainName),
485        )
486        ?.toList();
487      if (txt == null || txt.isEmpty) {
488        return MDnsObservatoryDiscoveryResult(srv.first.port, '');
489      }
490      String authCode = '';
491      const String authCodePrefix = 'authCode=';
492      String raw = txt.first.text;
493      // TXT has a format of [<length byte>, text], so if the length is 2,
494      // that means that TXT is empty.
495      if (raw.length > 2) {
496        // Remove length byte from raw txt.
497        raw = raw.substring(1);
498        if (raw.startsWith(authCodePrefix)) {
499          authCode = raw.substring(authCodePrefix.length);
500          // The Observatory currently expects a trailing '/' as part of the
501          // URI, otherwise an invalid authentication code response is given.
502          if (!authCode.endsWith('/')) {
503            authCode += '/';
504          }
505        }
506      }
507      return MDnsObservatoryDiscoveryResult(srv.first.port, authCode);
508    } finally {
509      client.stop();
510    }
511  }
512}
513