| 1 | // Copyright 2014 The Flutter 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 | |
| 5 | import 'dart:async'; |
| 6 | |
| 7 | import 'package:unified_analytics/unified_analytics.dart' ; |
| 8 | import 'package:vm_service/vm_service.dart' ; |
| 9 | |
| 10 | import '../android/android_device.dart'; |
| 11 | import '../base/common.dart'; |
| 12 | import '../base/file_system.dart'; |
| 13 | import '../base/io.dart'; |
| 14 | import '../base/logger.dart'; |
| 15 | import '../base/platform.dart'; |
| 16 | import '../base/signals.dart'; |
| 17 | import '../base/terminal.dart'; |
| 18 | import '../build_info.dart'; |
| 19 | import '../commands/daemon.dart'; |
| 20 | import '../compile.dart'; |
| 21 | import '../daemon.dart'; |
| 22 | import '../device.dart'; |
| 23 | import '../device_vm_service_discovery_for_attach.dart'; |
| 24 | import '../ios/devices.dart'; |
| 25 | import '../ios/simulators.dart'; |
| 26 | import '../macos/macos_ipad_device.dart'; |
| 27 | import '../mdns_discovery.dart'; |
| 28 | import '../project.dart'; |
| 29 | import '../resident_runner.dart'; |
| 30 | import '../run_cold.dart'; |
| 31 | import '../run_hot.dart'; |
| 32 | import '../runner/flutter_command.dart'; |
| 33 | import '../runner/flutter_command_runner.dart'; |
| 34 | |
| 35 | /// A Flutter-command that attaches to applications that have been launched |
| 36 | /// without `flutter run`. |
| 37 | /// |
| 38 | /// With an application already running, a HotRunner can be attached to it |
| 39 | /// with: |
| 40 | /// ```bash |
| 41 | /// $ flutter attach --debug-url http://127.0.0.1:12345/QqL7EFEDNG0=/ |
| 42 | /// ``` |
| 43 | /// |
| 44 | /// If `--disable-service-auth-codes` was provided to the application at startup |
| 45 | /// time, a HotRunner can be attached with just a port: |
| 46 | /// ```bash |
| 47 | /// $ flutter attach --debug-port 12345 |
| 48 | /// ``` |
| 49 | /// |
| 50 | /// Alternatively, the attach command can start listening and scan for new |
| 51 | /// programs that become active: |
| 52 | /// ```bash |
| 53 | /// $ flutter attach |
| 54 | /// ``` |
| 55 | /// As soon as a new VM Service is detected the command attaches to it and |
| 56 | /// enables hot reloading. |
| 57 | /// |
| 58 | /// To attach to a flutter mod running on a fuchsia device, `--module` must |
| 59 | /// also be provided. |
| 60 | class AttachCommand extends FlutterCommand { |
| 61 | AttachCommand({ |
| 62 | bool verboseHelp = false, |
| 63 | HotRunnerFactory? hotRunnerFactory, |
| 64 | required Stdio stdio, |
| 65 | required Logger logger, |
| 66 | required Terminal terminal, |
| 67 | required Signals signals, |
| 68 | required Platform platform, |
| 69 | required ProcessInfo processInfo, |
| 70 | required FileSystem fileSystem, |
| 71 | }) : _hotRunnerFactory = hotRunnerFactory ?? HotRunnerFactory(), |
| 72 | _stdio = stdio, |
| 73 | _logger = logger, |
| 74 | _terminal = terminal, |
| 75 | _signals = signals, |
| 76 | _platform = platform, |
| 77 | _processInfo = processInfo, |
| 78 | _fileSystem = fileSystem { |
| 79 | addBuildModeFlags(verboseHelp: verboseHelp, defaultToRelease: false, excludeRelease: true); |
| 80 | usesTargetOption(); |
| 81 | usesPortOptions(verboseHelp: verboseHelp); |
| 82 | usesIpv6Flag(verboseHelp: verboseHelp); |
| 83 | usesFilesystemOptions(hide: !verboseHelp); |
| 84 | usesFuchsiaOptions(hide: !verboseHelp); |
| 85 | usesDartDefineOption(); |
| 86 | usesDeviceUserOption(); |
| 87 | addEnableExperimentation(hide: !verboseHelp); |
| 88 | usesInitializeFromDillOption(hide: !verboseHelp); |
| 89 | usesNativeAssetsOption(hide: !verboseHelp); |
| 90 | argParser |
| 91 | ..addOption( |
| 92 | 'debug-port' , |
| 93 | hide: !verboseHelp, |
| 94 | help: |
| 95 | '(deprecated) Device port where the Dart VM Service is listening. Requires ' |
| 96 | '"--disable-service-auth-codes" to also be provided to the Flutter ' |
| 97 | 'application at launch, otherwise this command will fail to connect to ' |
| 98 | 'the application. In general, "--debug-url" should be used instead.' , |
| 99 | ) |
| 100 | ..addOption( |
| 101 | 'debug-url' , |
| 102 | aliases: <String>['debug-uri' ], // supported for historical reasons |
| 103 | help: 'The URL at which the Dart VM Service is listening.' , |
| 104 | ) |
| 105 | ..addOption( |
| 106 | 'app-id' , |
| 107 | help: |
| 108 | 'The package name (Android) or bundle identifier (iOS) for the app. ' |
| 109 | 'This can be specified to avoid being prompted if multiple Dart VM Service ports ' |
| 110 | 'are advertised.\n' |
| 111 | 'If you have multiple devices or emulators running, you should include the ' |
| 112 | 'device hostname as well, e.g. "com.example.myApp@my-iphone".\n' |
| 113 | 'This parameter is case-insensitive.' , |
| 114 | ) |
| 115 | ..addOption( |
| 116 | 'pid-file' , |
| 117 | help: |
| 118 | 'Specify a file to write the process ID to. ' |
| 119 | 'You can send SIGUSR1 to trigger a hot reload ' |
| 120 | 'and SIGUSR2 to trigger a hot restart. ' |
| 121 | 'The file is created when the signal handlers ' |
| 122 | 'are hooked and deleted when they are removed.' , |
| 123 | ) |
| 124 | ..addFlag( |
| 125 | 'report-ready' , |
| 126 | help: |
| 127 | 'Print "ready" to the console after handling a keyboard command.\n' |
| 128 | 'This is primarily useful for tests and other automation, but consider ' |
| 129 | 'using "--machine" instead.' , |
| 130 | hide: !verboseHelp, |
| 131 | ) |
| 132 | ..addOption('project-root' , hide: !verboseHelp, help: 'Normally used only in run target.' ); |
| 133 | addMachineOutputFlag(verboseHelp: verboseHelp); |
| 134 | usesTrackWidgetCreation(verboseHelp: verboseHelp); |
| 135 | addDdsOptions(verboseHelp: verboseHelp); |
| 136 | addDevToolsOptions(verboseHelp: verboseHelp); |
| 137 | usesDeviceTimeoutOption(); |
| 138 | usesDeviceConnectionOption(); |
| 139 | } |
| 140 | |
| 141 | final HotRunnerFactory _hotRunnerFactory; |
| 142 | final Stdio _stdio; |
| 143 | final Logger _logger; |
| 144 | final Terminal _terminal; |
| 145 | final Signals _signals; |
| 146 | final Platform _platform; |
| 147 | final ProcessInfo _processInfo; |
| 148 | final FileSystem _fileSystem; |
| 149 | |
| 150 | @override |
| 151 | final name = 'attach' ; |
| 152 | |
| 153 | @override |
| 154 | final description = r''' |
| 155 | Attach to a running app. |
| 156 | |
| 157 | For attaching to Android or iOS devices, simply using `flutter attach` is |
| 158 | usually sufficient. The tool will search for a running Flutter app or module, |
| 159 | if available. Otherwise, the tool will wait for the next Flutter app or module |
| 160 | to launch before attaching. |
| 161 | |
| 162 | For Fuchsia, the module name must be provided, e.g. `$flutter attach |
| 163 | --module=mod_name`. This can be called either before or after the application |
| 164 | is started. |
| 165 | |
| 166 | If the app or module is already running and the specific vmService port is |
| 167 | known, it can be explicitly provided to attach via the command-line, e.g. |
| 168 | `$ flutter attach --debug-port 12345`''' ; |
| 169 | |
| 170 | @override |
| 171 | final String category = FlutterCommandCategory.tools; |
| 172 | |
| 173 | @override |
| 174 | bool get refreshWirelessDevices => true; |
| 175 | |
| 176 | int? get debugPort { |
| 177 | if (argResults!['debug-port' ] == null) { |
| 178 | return null; |
| 179 | } |
| 180 | try { |
| 181 | return int.parse(stringArg('debug-port' )!); |
| 182 | } on Exception catch (error) { |
| 183 | throwToolExit('Invalid port for `--debug-port`: $error' ); |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | Uri? get debugUri { |
| 188 | final String? debugUrl = stringArg('debug-url' ); |
| 189 | if (debugUrl == null) { |
| 190 | return null; |
| 191 | } |
| 192 | final Uri? uri = Uri.tryParse(debugUrl); |
| 193 | if (uri == null) { |
| 194 | throwToolExit('Invalid `--debug-url`: $debugUrl' ); |
| 195 | } |
| 196 | if (!uri.hasPort) { |
| 197 | throwToolExit('Port not specified for `--debug-url`: $uri' ); |
| 198 | } |
| 199 | return uri; |
| 200 | } |
| 201 | |
| 202 | String? get appId { |
| 203 | return stringArg('app-id' ); |
| 204 | } |
| 205 | |
| 206 | String? get userIdentifier => stringArg(FlutterOptions.kDeviceUser); |
| 207 | |
| 208 | @override |
| 209 | Future<void> validateCommand() async { |
| 210 | // ARM macOS as an iOS target is hidden, except for attach. |
| 211 | MacOSDesignedForIPadDevices.allowDiscovery = true; |
| 212 | |
| 213 | await super.validateCommand(); |
| 214 | |
| 215 | final Device? targetDevice = await findTargetDevice(); |
| 216 | if (targetDevice == null) { |
| 217 | throwToolExit(null); |
| 218 | } |
| 219 | |
| 220 | debugPort; |
| 221 | // Allow --ipv6 for iOS devices even if --debug-port and --debug-url |
| 222 | // are unknown. |
| 223 | if (!_isIOSDevice(targetDevice) && |
| 224 | debugPort == null && |
| 225 | debugUri == null && |
| 226 | argResults!.wasParsed(FlutterCommand.ipv6Flag)) { |
| 227 | throwToolExit( |
| 228 | 'When the --debug-port or --debug-url is unknown, this command determines ' |
| 229 | 'the value of --ipv6 on its own.' , |
| 230 | ); |
| 231 | } |
| 232 | if (debugPort == null && |
| 233 | debugUri == null && |
| 234 | argResults!.wasParsed(FlutterCommand.vmServicePortOption)) { |
| 235 | throwToolExit( |
| 236 | 'When the --debug-port or --debug-url is unknown, this command does not use ' |
| 237 | 'the value of --vm-service-port.' , |
| 238 | ); |
| 239 | } |
| 240 | if (debugPort != null && debugUri != null) { |
| 241 | throwToolExit('Either --debug-port or --debug-url can be provided, not both.' ); |
| 242 | } |
| 243 | |
| 244 | if (userIdentifier != null) { |
| 245 | final Device? device = await findTargetDevice(); |
| 246 | if (device is! AndroidDevice) { |
| 247 | throwToolExit('-- ${FlutterOptions.kDeviceUser} is only supported for Android' ); |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | @override |
| 253 | Future<FlutterCommandResult> runCommand() async { |
| 254 | await _validateArguments(); |
| 255 | |
| 256 | final Device? device = await findTargetDevice(); |
| 257 | |
| 258 | if (device == null) { |
| 259 | throwToolExit('Did not find any valid target devices.' ); |
| 260 | } |
| 261 | |
| 262 | await _attachToDevice(device); |
| 263 | |
| 264 | return FlutterCommandResult.success(); |
| 265 | } |
| 266 | |
| 267 | Future<void> _attachToDevice(Device device) async { |
| 268 | final FlutterProject flutterProject = FlutterProject.current(); |
| 269 | |
| 270 | final Daemon? daemon = boolArg('machine' ) |
| 271 | ? Daemon( |
| 272 | DaemonConnection( |
| 273 | daemonStreams: DaemonStreams.fromStdio(_stdio, logger: _logger), |
| 274 | logger: _logger, |
| 275 | ), |
| 276 | notifyingLogger: (_logger is NotifyingLogger) |
| 277 | ? _logger |
| 278 | : NotifyingLogger(verbose: _logger.isVerbose, parent: _logger), |
| 279 | logToStdout: true, |
| 280 | ) |
| 281 | : null; |
| 282 | |
| 283 | Stream<Uri>? vmServiceUri; |
| 284 | final bool usesIpv6 = ipv6!; |
| 285 | final String ipv6Loopback = InternetAddress.loopbackIPv6.address; |
| 286 | final String ipv4Loopback = InternetAddress.loopbackIPv4.address; |
| 287 | final hostname = usesIpv6 ? ipv6Loopback : ipv4Loopback; |
| 288 | final bool isWirelessIOSDevice = (device is IOSDevice) && device.isWirelesslyConnected; |
| 289 | |
| 290 | if ((debugPort == null && debugUri == null) || isWirelessIOSDevice) { |
| 291 | // The device port we expect to have the debug port be listening |
| 292 | final int? devicePort = debugPort ?? debugUri?.port ?? deviceVmservicePort; |
| 293 | |
| 294 | final VMServiceDiscoveryForAttach vmServiceDiscovery = device.getVMServiceDiscoveryForAttach( |
| 295 | appId: appId, |
| 296 | fuchsiaModule: stringArg('module' ), |
| 297 | filterDevicePort: devicePort, |
| 298 | expectedHostPort: hostVmservicePort, |
| 299 | ipv6: usesIpv6, |
| 300 | logger: _logger, |
| 301 | ); |
| 302 | |
| 303 | _logger.printStatus('Waiting for a connection from Flutter on ${device.displayName}...' ); |
| 304 | final Status discoveryStatus = _logger.startSpinner( |
| 305 | timeout: const Duration(seconds: 30), |
| 306 | slowWarningCallback: () { |
| 307 | // On iOS we rely on mDNS to find Dart VM Service. |
| 308 | if (device is IOSSimulator) { |
| 309 | // mDNS on simulators stopped working in macOS 15.4. |
| 310 | // See https://github.com/flutter/flutter/issues/166333. |
| 311 | return 'The Dart VM Service was not discovered after 30 seconds. ' |
| 312 | 'This may be due to limited mDNS support in the iOS Simulator.\n\n' |
| 313 | 'Click "Allow" to the prompt on your device asking if you would like to find and connect devices on your local network. ' |
| 314 | 'If you selected "Don\'t Allow", you can turn it on in Settings > Your App Name > Local Network. ' |
| 315 | "If you don't see your app in the Settings, uninstall the app and rerun to see the prompt again.\n\n" |
| 316 | 'If you do not receive a prompt, either run "flutter attach" before starting the ' |
| 317 | 'app or use the Dart VM service URL from the Xcode console with ' |
| 318 | '"flutter attach --debug-url=<URL>".\n' ; |
| 319 | } else if (_isIOSDevice(device)) { |
| 320 | // Remind the user to allow local network permissions on the device. |
| 321 | return 'The Dart VM Service was not discovered after 30 seconds. This is taking much longer than expected...\n\n' |
| 322 | 'Click "Allow" to the prompt on your device asking if you would like to find and connect devices on your local network. ' |
| 323 | 'If you selected "Don\'t Allow", you can turn it on in Settings > Your App Name > Local Network. ' |
| 324 | "If you don't see your app in the Settings, uninstall the app and rerun to see the prompt again.\n" ; |
| 325 | } |
| 326 | |
| 327 | return 'The Dart VM Service was not discovered after 30 seconds. This is taking much longer than expected...\n' ; |
| 328 | }, |
| 329 | warningColor: TerminalColor.cyan, |
| 330 | ); |
| 331 | |
| 332 | vmServiceUri = vmServiceDiscovery.uris; |
| 333 | |
| 334 | // Stop the timer once we receive the first uri. |
| 335 | vmServiceUri = vmServiceUri.map((Uri uri) { |
| 336 | discoveryStatus.stop(); |
| 337 | return uri; |
| 338 | }); |
| 339 | } else { |
| 340 | vmServiceUri = Stream<Uri>.fromFuture( |
| 341 | buildVMServiceUri( |
| 342 | device, |
| 343 | debugUri?.host ?? hostname, |
| 344 | debugPort ?? debugUri!.port, |
| 345 | hostVmservicePort, |
| 346 | debugUri?.path, |
| 347 | ), |
| 348 | ).asBroadcastStream(); |
| 349 | } |
| 350 | |
| 351 | _terminal.usesTerminalUi = daemon == null; |
| 352 | |
| 353 | try { |
| 354 | int? result; |
| 355 | if (daemon != null) { |
| 356 | final ResidentRunner runner = await createResidentRunner( |
| 357 | vmServiceUris: vmServiceUri, |
| 358 | device: device, |
| 359 | flutterProject: flutterProject, |
| 360 | usesIpv6: usesIpv6, |
| 361 | ); |
| 362 | late AppInstance app; |
| 363 | try { |
| 364 | app = await daemon.appDomain.launch( |
| 365 | runner, |
| 366 | ({ |
| 367 | Completer<DebugConnectionInfo>? connectionInfoCompleter, |
| 368 | Completer<void>? appStartedCompleter, |
| 369 | }) { |
| 370 | return runner.attach( |
| 371 | connectionInfoCompleter: connectionInfoCompleter, |
| 372 | appStartedCompleter: appStartedCompleter, |
| 373 | allowExistingDdsInstance: true, |
| 374 | ); |
| 375 | }, |
| 376 | device, |
| 377 | null, |
| 378 | true, |
| 379 | _fileSystem.currentDirectory, |
| 380 | LaunchMode.attach, |
| 381 | _logger as AppRunLogger, |
| 382 | ); |
| 383 | } on Exception catch (error) { |
| 384 | throwToolExit(error.toString()); |
| 385 | } |
| 386 | result = await app.runner.waitForAppToFinish(); |
| 387 | return; |
| 388 | } |
| 389 | while (true) { |
| 390 | final ResidentRunner runner = await createResidentRunner( |
| 391 | vmServiceUris: vmServiceUri, |
| 392 | device: device, |
| 393 | flutterProject: flutterProject, |
| 394 | usesIpv6: usesIpv6, |
| 395 | ); |
| 396 | final onAppStart = Completer<void>.sync(); |
| 397 | TerminalHandler? terminalHandler; |
| 398 | unawaited( |
| 399 | onAppStart.future.whenComplete(() { |
| 400 | terminalHandler = |
| 401 | TerminalHandler( |
| 402 | runner, |
| 403 | logger: _logger, |
| 404 | terminal: _terminal, |
| 405 | signals: _signals, |
| 406 | processInfo: _processInfo, |
| 407 | reportReady: boolArg('report-ready' ), |
| 408 | pidFile: stringArg('pid-file' ), |
| 409 | ) |
| 410 | ..registerSignalHandlers() |
| 411 | ..setupTerminal(); |
| 412 | }), |
| 413 | ); |
| 414 | result = await runner.attach( |
| 415 | appStartedCompleter: onAppStart, |
| 416 | allowExistingDdsInstance: true, |
| 417 | ); |
| 418 | if (result != 0) { |
| 419 | throwToolExit(null, exitCode: result); |
| 420 | } |
| 421 | terminalHandler?.stop(); |
| 422 | assert(result != null); |
| 423 | if (runner.exited || !runner.isWaitingForVmService) { |
| 424 | break; |
| 425 | } |
| 426 | _logger.printStatus( |
| 427 | 'Waiting for a new connection from Flutter on ' |
| 428 | ' ${device.displayName}...' , |
| 429 | ); |
| 430 | } |
| 431 | } on RPCError catch (err) { |
| 432 | if (err.code == RPCErrorKind.kServiceDisappeared.code || |
| 433 | err.code == RPCErrorKind.kConnectionDisposed.code || |
| 434 | err.message.contains('Service connection disposed' )) { |
| 435 | throwToolExit('Lost connection to device.' ); |
| 436 | } |
| 437 | rethrow; |
| 438 | } finally { |
| 439 | // However we exited from the runner, ensure the terminal has line mode |
| 440 | // and echo mode enabled before we return the user to the shell. |
| 441 | try { |
| 442 | _terminal.singleCharMode = false; |
| 443 | } on StdinException { |
| 444 | // Do nothing, if the STDIN handle is no longer available, there is nothing actionable for us to do at this point |
| 445 | } |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | Future<ResidentRunner> createResidentRunner({ |
| 450 | required Stream<Uri> vmServiceUris, |
| 451 | required Device device, |
| 452 | required FlutterProject flutterProject, |
| 453 | required bool usesIpv6, |
| 454 | }) async { |
| 455 | final BuildInfo buildInfo = await getBuildInfo(); |
| 456 | |
| 457 | final FlutterDevice flutterDevice = await FlutterDevice.create( |
| 458 | device, |
| 459 | target: targetFile, |
| 460 | targetModel: TargetModel(stringArg('target-model' )!), |
| 461 | buildInfo: buildInfo, |
| 462 | userIdentifier: userIdentifier, |
| 463 | platform: _platform, |
| 464 | ); |
| 465 | flutterDevice.vmServiceUris = vmServiceUris; |
| 466 | final flutterDevices = <FlutterDevice>[flutterDevice]; |
| 467 | final debuggingOptions = DebuggingOptions.enabled( |
| 468 | buildInfo, |
| 469 | enableDds: enableDds, |
| 470 | ddsPort: ddsPort, |
| 471 | devToolsServerAddress: devToolsServerAddress, |
| 472 | usingCISystem: usingCISystem, |
| 473 | debugLogsDirectoryPath: debugLogsDirectoryPath, |
| 474 | enableDevTools: boolArg(FlutterCommand.kEnableDevTools), |
| 475 | ipv6: usesIpv6, |
| 476 | printDtd: boolArg(FlutterGlobalOptions.kPrintDtd, global: true), |
| 477 | ); |
| 478 | |
| 479 | return buildInfo.isDebug |
| 480 | ? _hotRunnerFactory.build( |
| 481 | flutterDevices, |
| 482 | target: targetFile, |
| 483 | debuggingOptions: debuggingOptions, |
| 484 | packagesFilePath: globalResults![FlutterGlobalOptions.kPackagesOption] as String?, |
| 485 | projectRootPath: stringArg('project-root' ), |
| 486 | dillOutputPath: stringArg('output-dill' ), |
| 487 | flutterProject: flutterProject, |
| 488 | nativeAssetsYamlFile: stringArg(FlutterOptions.kNativeAssetsYamlFile), |
| 489 | analytics: analytics, |
| 490 | ) |
| 491 | : ColdRunner(flutterDevices, target: targetFile, debuggingOptions: debuggingOptions); |
| 492 | } |
| 493 | |
| 494 | Future<void> _validateArguments() async {} |
| 495 | |
| 496 | bool _isIOSDevice(Device device) { |
| 497 | return (device.platformType == PlatformType.ios) || (device is MacOSDesignedForIPadDevice); |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | class HotRunnerFactory { |
| 502 | HotRunner build( |
| 503 | List<FlutterDevice> devices, { |
| 504 | required String target, |
| 505 | required DebuggingOptions debuggingOptions, |
| 506 | bool benchmarkMode = false, |
| 507 | File? applicationBinary, |
| 508 | bool hostIsIde = false, |
| 509 | String? projectRootPath, |
| 510 | String? packagesFilePath, |
| 511 | String? dillOutputPath, |
| 512 | bool stayResident = true, |
| 513 | FlutterProject? flutterProject, |
| 514 | String? nativeAssetsYamlFile, |
| 515 | required Analytics analytics, |
| 516 | }) => HotRunner( |
| 517 | devices, |
| 518 | target: target, |
| 519 | debuggingOptions: debuggingOptions, |
| 520 | benchmarkMode: benchmarkMode, |
| 521 | applicationBinary: applicationBinary, |
| 522 | hostIsIde: hostIsIde, |
| 523 | projectRootPath: projectRootPath, |
| 524 | dillOutputPath: dillOutputPath, |
| 525 | stayResident: stayResident, |
| 526 | nativeAssetsYamlFile: nativeAssetsYamlFile, |
| 527 | analytics: analytics, |
| 528 | ); |
| 529 | } |
| 530 | |