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
5import 'dart:async';
6
7import 'package:unified_analytics/unified_analytics.dart';
8import 'package:vm_service/vm_service.dart';
9
10import '../android/android_device.dart';
11import '../base/common.dart';
12import '../base/file_system.dart';
13import '../base/io.dart';
14import '../base/logger.dart';
15import '../base/platform.dart';
16import '../base/signals.dart';
17import '../base/terminal.dart';
18import '../build_info.dart';
19import '../commands/daemon.dart';
20import '../compile.dart';
21import '../daemon.dart';
22import '../device.dart';
23import '../device_vm_service_discovery_for_attach.dart';
24import '../ios/devices.dart';
25import '../ios/simulators.dart';
26import '../macos/macos_ipad_device.dart';
27import '../mdns_discovery.dart';
28import '../project.dart';
29import '../resident_runner.dart';
30import '../run_cold.dart';
31import '../run_hot.dart';
32import '../runner/flutter_command.dart';
33import '../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.
60class 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'''
155Attach to a running app.
156
157For attaching to Android or iOS devices, simply using `flutter attach` is
158usually sufficient. The tool will search for a running Flutter app or module,
159if available. Otherwise, the tool will wait for the next Flutter app or module
160to launch before attaching.
161
162For 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
164is started.
165
166If the app or module is already running and the specific vmService port is
167known, 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
501class 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