| // Copyright 2018 The Chromium 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 'dart:async'; |
| |
| import 'package:multicast_dns/multicast_dns.dart'; |
| |
| import '../artifacts.dart'; |
| import '../base/common.dart'; |
| import '../base/context.dart'; |
| import '../base/file_system.dart'; |
| import '../base/io.dart'; |
| import '../base/terminal.dart'; |
| import '../base/utils.dart'; |
| import '../cache.dart'; |
| import '../commands/daemon.dart'; |
| import '../compile.dart'; |
| import '../device.dart'; |
| import '../fuchsia/fuchsia_device.dart'; |
| import '../globals.dart'; |
| import '../ios/devices.dart'; |
| import '../ios/simulators.dart'; |
| import '../project.dart'; |
| import '../protocol_discovery.dart'; |
| import '../resident_runner.dart'; |
| import '../run_cold.dart'; |
| import '../run_hot.dart'; |
| import '../runner/flutter_command.dart'; |
| |
| /// A Flutter-command that attaches to applications that have been launched |
| /// without `flutter run`. |
| /// |
| /// With an application already running, a HotRunner can be attached to it |
| /// with: |
| /// ``` |
| /// $ flutter attach --debug-uri http://127.0.0.1:12345/QqL7EFEDNG0=/ |
| /// ``` |
| /// |
| /// If `--disable-service-auth-codes` was provided to the application at startup |
| /// time, a HotRunner can be attached with just a port: |
| /// ``` |
| /// $ flutter attach --debug-port 12345 |
| /// ``` |
| /// |
| /// Alternatively, the attach command can start listening and scan for new |
| /// programs that become active: |
| /// ``` |
| /// $ flutter attach |
| /// ``` |
| /// As soon as a new observatory is detected the command attaches to it and |
| /// enables hot reloading. |
| /// |
| /// To attach to a flutter mod running on a fuchsia device, `--module` must |
| /// also be provided. |
| class AttachCommand extends FlutterCommand { |
| AttachCommand({bool verboseHelp = false, this.hotRunnerFactory}) { |
| addBuildModeFlags(defaultToRelease: false); |
| usesIsolateFilterOption(hide: !verboseHelp); |
| usesTargetOption(); |
| usesPortOptions(); |
| usesIpv6Flag(); |
| usesFilesystemOptions(hide: !verboseHelp); |
| usesFuchsiaOptions(hide: !verboseHelp); |
| argParser |
| ..addOption( |
| 'debug-port', |
| hide: !verboseHelp, |
| help: 'Device port where the observatory is listening. Requires ' |
| '--disable-service-auth-codes to also be provided to the Flutter ' |
| 'application at launch, otherwise this command will fail to connect to ' |
| 'the application. In general, --debug-uri should be used instead.', |
| )..addOption( |
| 'debug-uri', |
| help: 'The URI at which the observatory is listening.', |
| )..addOption( |
| 'app-id', |
| help: 'The package name (Android) or bundle identifier (iOS) for the application. ' |
| 'This can be specified to avoid being prompted if multiple observatory ports ' |
| 'are advertised.\n' |
| 'If you have multiple devices or emulators running, you should include the ' |
| 'device hostname as well, e.g. "com.example.myApp@my-iphone".\n' |
| 'This parameter is case-insensitive.', |
| )..addOption( |
| 'pid-file', |
| help: 'Specify a file to write the process id to. ' |
| 'You can send SIGUSR1 to trigger a hot reload ' |
| 'and SIGUSR2 to trigger a hot restart.', |
| )..addOption( |
| 'project-root', |
| hide: !verboseHelp, |
| help: 'Normally used only in run target', |
| )..addFlag('machine', |
| hide: !verboseHelp, |
| negatable: false, |
| help: 'Handle machine structured JSON command input and provide output ' |
| 'and progress in machine friendly format.', |
| ); |
| usesTrackWidgetCreation(verboseHelp: verboseHelp); |
| hotRunnerFactory ??= HotRunnerFactory(); |
| } |
| |
| HotRunnerFactory hotRunnerFactory; |
| |
| @override |
| final String name = 'attach'; |
| |
| @override |
| final String description = 'Attach to a running application.'; |
| |
| int get debugPort { |
| if (argResults['debug-port'] == null) |
| return null; |
| try { |
| return int.parse(argResults['debug-port']); |
| } catch (error) { |
| throwToolExit('Invalid port for `--debug-port`: $error'); |
| } |
| return null; |
| } |
| |
| Uri get debugUri { |
| if (argResults['debug-uri'] == null) { |
| return null; |
| } |
| final Uri uri = Uri.parse(argResults['debug-uri']); |
| if (!uri.hasPort) { |
| throwToolExit('Port not specified for `--debug-uri`: $uri'); |
| } |
| return uri; |
| } |
| |
| String get appId { |
| return argResults['app-id']; |
| } |
| |
| @override |
| Future<void> validateCommand() async { |
| await super.validateCommand(); |
| if (await findTargetDevice() == null) |
| throwToolExit(null); |
| debugPort; |
| if (debugPort == null && debugUri == null && argResults.wasParsed(FlutterCommand.ipv6Flag)) { |
| throwToolExit( |
| 'When the --debug-port or --debug-uri is unknown, this command determines ' |
| 'the value of --ipv6 on its own.', |
| ); |
| } |
| if (debugPort == null && debugUri == null && argResults.wasParsed(FlutterCommand.observatoryPortOption)) { |
| throwToolExit( |
| 'When the --debug-port or --debug-uri is unknown, this command does not use ' |
| 'the value of --observatory-port.', |
| ); |
| } |
| if (debugPort != null && debugUri != null) { |
| throwToolExit( |
| 'Either --debugPort or --debugUri can be provided, not both.'); |
| } |
| } |
| |
| @override |
| Future<FlutterCommandResult> runCommand() async { |
| Cache.releaseLockEarly(); |
| |
| await _validateArguments(); |
| |
| writePidFile(argResults['pid-file']); |
| |
| final Device device = await findTargetDevice(); |
| |
| final Artifacts artifacts = device.artifactOverrides ?? Artifacts.instance; |
| await context.run<void>( |
| body: () => _attachToDevice(device), |
| overrides: <Type, Generator>{ |
| Artifacts: () => artifacts, |
| }); |
| |
| return null; |
| } |
| |
| Future<void> _attachToDevice(Device device) async { |
| final FlutterProject flutterProject = FlutterProject.current(); |
| Future<int> getDevicePort() async { |
| if (debugPort != null) { |
| return debugPort; |
| } |
| // This call takes a non-trivial amount of time, and only iOS devices and |
| // simulators support it. |
| // If/when we do this on Android or other platforms, we can update it here. |
| if (device is IOSDevice || device is IOSSimulator) { |
| } |
| return null; |
| } |
| final int devicePort = await getDevicePort(); |
| |
| final Daemon daemon = argResults['machine'] |
| ? Daemon(stdinCommandStream, stdoutCommandResponse, |
| notifyingLogger: NotifyingLogger(), logToStdout: true) |
| : null; |
| |
| Uri observatoryUri; |
| bool usesIpv6 = ipv6; |
| final String ipv6Loopback = InternetAddress.loopbackIPv6.address; |
| final String ipv4Loopback = InternetAddress.loopbackIPv4.address; |
| final String hostname = usesIpv6 ? ipv6Loopback : ipv4Loopback; |
| |
| bool attachLogger = false; |
| if (devicePort == null && debugUri == null) { |
| if (device is FuchsiaDevice) { |
| attachLogger = true; |
| final String module = argResults['module']; |
| if (module == null) |
| throwToolExit('\'--module\' is required for attaching to a Fuchsia device'); |
| usesIpv6 = device.ipv6; |
| FuchsiaIsolateDiscoveryProtocol isolateDiscoveryProtocol; |
| try { |
| isolateDiscoveryProtocol = device.getIsolateDiscoveryProtocol(module); |
| observatoryUri = await isolateDiscoveryProtocol.uri; |
| printStatus('Done.'); // FYI, this message is used as a sentinel in tests. |
| } catch (_) { |
| isolateDiscoveryProtocol?.dispose(); |
| final List<ForwardedPort> ports = device.portForwarder.forwardedPorts.toList(); |
| for (ForwardedPort port in ports) { |
| await device.portForwarder.unforward(port); |
| } |
| rethrow; |
| } |
| } else if ((device is IOSDevice) || (device is IOSSimulator)) { |
| final MDnsObservatoryDiscoveryResult result = await MDnsObservatoryDiscovery().query(applicationId: appId); |
| if (result != null) { |
| observatoryUri = await _buildObservatoryUri(device, hostname, result.port, result.authCode); |
| } |
| } |
| // If MDNS discovery fails or we're not on iOS, fallback to ProtocolDiscovery. |
| if (observatoryUri == null) { |
| ProtocolDiscovery observatoryDiscovery; |
| try { |
| observatoryDiscovery = ProtocolDiscovery.observatory( |
| device.getLogReader(), |
| portForwarder: device.portForwarder, |
| ); |
| printStatus('Waiting for a connection from Flutter on ${device.name}...'); |
| observatoryUri = await observatoryDiscovery.uri; |
| // Determine ipv6 status from the scanned logs. |
| usesIpv6 = observatoryDiscovery.ipv6; |
| printStatus('Done.'); // FYI, this message is used as a sentinel in tests. |
| } catch (error) { |
| throwToolExit('Failed to establish a debug connection with ${device.name}: $error'); |
| } finally { |
| await observatoryDiscovery?.cancel(); |
| } |
| } |
| } else { |
| observatoryUri = await _buildObservatoryUri(device, |
| debugUri?.host ?? hostname, devicePort ?? debugUri.port, debugUri?.path); |
| } |
| try { |
| final bool useHot = getBuildInfo().isDebug; |
| final FlutterDevice flutterDevice = await FlutterDevice.create( |
| device, |
| flutterProject: flutterProject, |
| trackWidgetCreation: argResults['track-widget-creation'], |
| fileSystemRoots: argResults['filesystem-root'], |
| fileSystemScheme: argResults['filesystem-scheme'], |
| viewFilter: argResults['isolate-filter'], |
| target: argResults['target'], |
| targetModel: TargetModel(argResults['target-model']), |
| buildMode: getBuildMode(), |
| ); |
| flutterDevice.observatoryUris = <Uri>[ observatoryUri ]; |
| final List<FlutterDevice> flutterDevices = <FlutterDevice>[flutterDevice]; |
| final DebuggingOptions debuggingOptions = DebuggingOptions.enabled(getBuildInfo()); |
| terminal.usesTerminalUi = daemon == null; |
| final ResidentRunner runner = useHot ? |
| hotRunnerFactory.build( |
| flutterDevices, |
| target: targetFile, |
| debuggingOptions: debuggingOptions, |
| packagesFilePath: globalResults['packages'], |
| projectRootPath: argResults['project-root'], |
| dillOutputPath: argResults['output-dill'], |
| ipv6: usesIpv6, |
| flutterProject: flutterProject, |
| ) |
| : ColdRunner( |
| flutterDevices, |
| target: targetFile, |
| debuggingOptions: debuggingOptions, |
| ipv6: usesIpv6, |
| ); |
| if (attachLogger) { |
| flutterDevice.startEchoingDeviceLog(); |
| } |
| |
| int result; |
| if (daemon != null) { |
| AppInstance app; |
| try { |
| app = await daemon.appDomain.launch( |
| runner, |
| runner.attach, |
| device, |
| null, |
| true, |
| fs.currentDirectory, |
| LaunchMode.attach, |
| ); |
| } catch (error) { |
| throwToolExit(error.toString()); |
| } |
| result = await app.runner.waitForAppToFinish(); |
| assert(result != null); |
| } else { |
| final Completer<void> onAppStart = Completer<void>.sync(); |
| unawaited(onAppStart.future.whenComplete(() { |
| TerminalHandler(runner) |
| ..setupTerminal() |
| ..registerSignalHandlers(); |
| })); |
| result = await runner.attach( |
| appStartedCompleter: onAppStart, |
| ); |
| assert(result != null); |
| } |
| if (result != 0) { |
| throwToolExit(null, exitCode: result); |
| } |
| } finally { |
| final List<ForwardedPort> ports = device.portForwarder.forwardedPorts.toList(); |
| for (ForwardedPort port in ports) { |
| await device.portForwarder.unforward(port); |
| } |
| } |
| } |
| |
| Future<void> _validateArguments() async { } |
| |
| Future<Uri> _buildObservatoryUri(Device device, |
| String host, int devicePort, [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 += '/'; |
| } |
| final int localPort = observatoryPort |
| ?? await device.portForwarder.forward(devicePort); |
| return Uri(scheme: 'http', host: host, port: localPort, path: path); |
| } |
| } |
| |
| class HotRunnerFactory { |
| HotRunner build( |
| List<FlutterDevice> devices, { |
| String target, |
| DebuggingOptions debuggingOptions, |
| bool benchmarkMode = false, |
| File applicationBinary, |
| bool hostIsIde = false, |
| String projectRootPath, |
| String packagesFilePath, |
| String dillOutputPath, |
| bool stayResident = true, |
| bool ipv6 = false, |
| FlutterProject flutterProject, |
| }) => HotRunner( |
| devices, |
| target: target, |
| debuggingOptions: debuggingOptions, |
| benchmarkMode: benchmarkMode, |
| applicationBinary: applicationBinary, |
| hostIsIde: hostIsIde, |
| projectRootPath: projectRootPath, |
| packagesFilePath: packagesFilePath, |
| dillOutputPath: dillOutputPath, |
| stayResident: stayResident, |
| ipv6: ipv6, |
| ); |
| } |
| |
| class MDnsObservatoryDiscoveryResult { |
| MDnsObservatoryDiscoveryResult(this.port, this.authCode); |
| final int port; |
| final String authCode; |
| } |
| |
| /// 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; |
| |
| static const String dartObservatoryName = '_dartobservatory._tcp.local'; |
| |
| /// 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. |
| Future<MDnsObservatoryDiscoveryResult> query({String applicationId}) async { |
| printStatus('Checking for advertised Dart observatories...'); |
| try { |
| await client.start(); |
| final List<PtrResourceRecord> pointerRecords = await client |
| .lookup<PtrResourceRecord>( |
| ResourceRecordQuery.serverPointer(dartObservatoryName), |
| ) |
| .toList(); |
| if (pointerRecords.isEmpty) { |
| return null; |
| } |
| // We have no guarantee that we won't get multiple hits from the same |
| // service on this. |
| final List<String> uniqueDomainNames = pointerRecords |
| .map<String>((PtrResourceRecord record) => record.domainName) |
| .toSet() |
| .toList(); |
| |
| String domainName; |
| if (applicationId != null) { |
| for (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; |
| } |
| printStatus('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) { |
| printError('Unexpectedly found more than one observatory report for $domainName ' |
| '- using first one (${srv.first.port}).'); |
| } |
| printStatus('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, ''); |
| } |
| String authCode = ''; |
| const String authCodePrefix = 'authCode='; |
| String raw = txt.first.text; |
| // TXT has a format of [<length byte>, text], so if the length is 2, |
| // that means that TXT is empty. |
| if (raw.length > 2) { |
| // Remove length byte from raw txt. |
| raw = raw.substring(1); |
| if (raw.startsWith(authCodePrefix)) { |
| 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(); |
| } |
| } |
| } |