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