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 | |