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:meta/meta.dart';
8import 'package:multicast_dns/multicast_dns.dart';
9import 'package:unified_analytics/unified_analytics.dart';
10
11import 'base/common.dart';
12import 'base/context.dart';
13import 'base/io.dart';
14import 'base/logger.dart';
15import 'build_info.dart';
16import 'convert.dart';
17import 'device.dart';
18import 'globals.dart' as globals;
19
20String _missingLocalNetworkPermissionsInstructions(String err) =>
21 '''
22Flutter could not access the local network.
23
24Please ensure your IDE or terminal app has permission to access devices on the local network. This allows Flutter to connect to the Dart VM.
25
26You can grant this permission in System Settings > Privacy & Security > Local Network.
27
28$err
29''';
30
31/// A wrapper around [MDnsClient] to find a Dart VM Service instance.
32class MDnsVmServiceDiscovery {
33 /// Creates a new [MDnsVmServiceDiscovery] object.
34 ///
35 /// The [_client] parameter will be defaulted to a new [MDnsClient] if null.
36 MDnsVmServiceDiscovery({
37 MDnsClient? mdnsClient,
38 MDnsClient? preliminaryMDnsClient,
39 required Logger logger,
40 required Analytics analytics,
41 }) : _client = mdnsClient ?? MDnsClient(),
42 _preliminaryClient = preliminaryMDnsClient,
43 _logger = logger,
44 _analytics = analytics;
45
46 final MDnsClient _client;
47
48 // Used when discovering VM services with `queryForAttach` to do a preliminary
49 // check for already running services so that results are not cached in _client.
50 final MDnsClient? _preliminaryClient;
51
52 final Logger _logger;
53 final Analytics _analytics;
54
55 @visibleForTesting
56 static const dartVmServiceName = '_dartVmService._tcp.local';
57
58 static MDnsVmServiceDiscovery? get instance => context.get<MDnsVmServiceDiscovery>();
59
60 /// Executes an mDNS query for Dart VM Services.
61 /// Checks for services that have already been launched.
62 /// If none are found, it will listen for new services to become active
63 /// and return the first it finds that match the parameters.
64 ///
65 /// The [applicationId] parameter may be used to specify which application
66 /// to find. For Android, it refers to the package name; on iOS, it refers to
67 /// the bundle ID.
68 ///
69 /// The [deviceVmservicePort] parameter may be used to specify which port
70 /// to find.
71 ///
72 /// The [useDeviceIPAsHost] parameter flags whether to get the device IP
73 /// and the [ipv6] parameter flags whether to get an iPv6 address
74 /// (otherwise it will get iPv4).
75 ///
76 /// The [timeout] parameter determines how long to continue to wait for
77 /// services to become active.
78 ///
79 /// If [applicationId] is not null, this method will find the port and authentication code
80 /// of the Dart VM Service for that application. If it cannot find a service matching
81 /// that application identifier after the [timeout], it will call [throwToolExit].
82 ///
83 /// If [applicationId] is null and there are multiple Dart VM Services available,
84 /// the user will be prompted with a list of available services with the respective
85 /// app-id and device-vmservice-port to use and asked to select one.
86 ///
87 /// If it is null and there is only one available or it's the first found instance
88 /// of Dart VM Service, it will return that instance's information regardless of
89 /// what application the service instance is for.
90 @visibleForTesting
91 Future<MDnsVmServiceDiscoveryResult?> queryForAttach({
92 String? applicationId,
93 int? deviceVmservicePort,
94 bool ipv6 = false,
95 bool useDeviceIPAsHost = false,
96 Duration timeout = const Duration(minutes: 10),
97 bool throwOnMissingLocalNetworkPermissionsError = true,
98 }) async {
99 // Poll for 5 seconds to see if there are already services running.
100 // Use a new instance of MDnsClient so results don't get cached in _client.
101 // If no results are found, poll for a longer duration to wait for connections.
102 // If more than 1 result is found, throw an error since it can't be determined which to pick.
103 // If only one is found, return it.
104 final List<MDnsVmServiceDiscoveryResult> results = await _pollingVmService(
105 _preliminaryClient ?? MDnsClient(),
106 applicationId: applicationId,
107 deviceVmServicePort: deviceVmservicePort,
108 ipv6: ipv6,
109 useDeviceIPAsHost: useDeviceIPAsHost,
110 timeout: const Duration(seconds: 5),
111 throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError,
112 );
113 if (results.isEmpty) {
114 return firstMatchingVmService(
115 _client,
116 applicationId: applicationId,
117 deviceVmservicePort: deviceVmservicePort,
118 ipv6: ipv6,
119 useDeviceIPAsHost: useDeviceIPAsHost,
120 timeout: timeout,
121 );
122 } else if (results.length > 1) {
123 final buffer = StringBuffer();
124 buffer.writeln('There are multiple Dart VM Services available.');
125 buffer.writeln(
126 'Rerun this command with one of the following passed in as the app-id and device-vmservice-port:',
127 );
128 buffer.writeln();
129 for (final result in results) {
130 buffer.writeln(
131 ' flutter attach --app-id "${result.domainName.replaceAll('.$dartVmServiceName', '')}" --device-vmservice-port ${result.port}',
132 );
133 }
134 throwToolExit(buffer.toString());
135 }
136 return results.first;
137 }
138
139 /// Executes an mDNS query for Dart VM Services.
140 /// Listens for new services to become active and returns the first it finds that
141 /// match the parameters.
142 ///
143 /// The [applicationId] parameter must be set to specify which application
144 /// to find. For Android, it refers to the package name; on iOS, it refers to
145 /// the bundle ID.
146 ///
147 /// The [deviceVmservicePort] parameter must be set to specify which port
148 /// to find.
149 ///
150 /// [applicationId] and either [deviceVmservicePort] or [deviceName] are
151 /// required for launch so that if multiple flutter apps are running on
152 /// different devices, it will only match with the device running the desired app.
153 ///
154 /// The [useDeviceIPAsHost] parameter flags whether to get the device IP
155 /// and the [ipv6] parameter flags whether to get an iPv6 address
156 /// (otherwise it will get iPv4).
157 ///
158 /// The [timeout] parameter determines how long to continue to wait for
159 /// services to become active.
160 ///
161 /// If a Dart VM Service matching the [applicationId] and
162 /// [deviceVmservicePort]/[deviceName] cannot be found before the [timeout]
163 /// is reached, it will call [throwToolExit].
164 @visibleForTesting
165 Future<MDnsVmServiceDiscoveryResult?> queryForLaunch({
166 required String applicationId,
167 int? deviceVmservicePort,
168 String? deviceName,
169 bool ipv6 = false,
170 bool useDeviceIPAsHost = false,
171 Duration timeout = const Duration(minutes: 10),
172 bool throwOnMissingLocalNetworkPermissionsError = true,
173 }) async {
174 // Either the device port or the device name must be provided.
175 assert(deviceVmservicePort != null || deviceName != null);
176
177 // Query for a specific application matching on either device port or device name.
178 return firstMatchingVmService(
179 _client,
180 applicationId: applicationId,
181 deviceVmservicePort: deviceVmservicePort,
182 deviceName: deviceName,
183 ipv6: ipv6,
184 useDeviceIPAsHost: useDeviceIPAsHost,
185 timeout: timeout,
186 throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError,
187 );
188 }
189
190 /// Polls for Dart VM Services and returns the first it finds that match
191 /// the [applicationId]/[deviceVmservicePort] (if applicable).
192 /// Returns null if no results are found.
193 @visibleForTesting
194 Future<MDnsVmServiceDiscoveryResult?> firstMatchingVmService(
195 MDnsClient client, {
196 String? applicationId,
197 int? deviceVmservicePort,
198 String? deviceName,
199 bool ipv6 = false,
200 bool useDeviceIPAsHost = false,
201 Duration timeout = const Duration(minutes: 10),
202 bool throwOnMissingLocalNetworkPermissionsError = true,
203 }) async {
204 final List<MDnsVmServiceDiscoveryResult> results = await _pollingVmService(
205 client,
206 applicationId: applicationId,
207 deviceVmServicePort: deviceVmservicePort,
208 deviceName: deviceName,
209 ipv6: ipv6,
210 useDeviceIPAsHost: useDeviceIPAsHost,
211 timeout: timeout,
212 quitOnFind: true,
213 throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError,
214 );
215 if (results.isEmpty) {
216 return null;
217 }
218 return results.first;
219 }
220
221 Future<List<MDnsVmServiceDiscoveryResult>> _pollingVmService(
222 MDnsClient client, {
223 String? applicationId,
224 int? deviceVmServicePort,
225 String? deviceName,
226 bool ipv6 = false,
227 bool useDeviceIPAsHost = false,
228 required Duration timeout,
229 bool quitOnFind = false,
230 bool throwOnMissingLocalNetworkPermissionsError = true,
231 }) async {
232 // macOS blocks mDNS unless the app has Local Network permissions.
233 // Since the mDNS client does not handle errors from the socket's stream,
234 // socket exceptions are routed to the current zone. Create an error zone to
235 // catch the socket exception.
236 // See: https://github.com/flutter/flutter/issues/150131
237 final completer = Completer<List<MDnsVmServiceDiscoveryResult>>();
238 unawaited(
239 runZonedGuarded(
240 () async {
241 final List<MDnsVmServiceDiscoveryResult> results = await _doPollingVmService(
242 client,
243 applicationId: applicationId,
244 deviceVmServicePort: deviceVmServicePort,
245 deviceName: deviceName,
246 ipv6: ipv6,
247 useDeviceIPAsHost: useDeviceIPAsHost,
248 timeout: timeout,
249 quitOnFind: quitOnFind,
250 );
251
252 if (!completer.isCompleted) {
253 completer.complete(results);
254 }
255 },
256 (Object error, StackTrace stackTrace) {
257 if (!completer.isCompleted) {
258 completer.completeError(error, stackTrace);
259 }
260 },
261 ),
262 );
263
264 try {
265 return await completer.future;
266 } on SocketException catch (e, stackTrace) {
267 if (!globals.platform.isMacOS) {
268 rethrow;
269 }
270
271 _logger.printTrace(stackTrace.toString());
272 if (throwOnMissingLocalNetworkPermissionsError) {
273 throwToolExit(_missingLocalNetworkPermissionsInstructions(e.toString()));
274 } else {
275 _logger.printError(_missingLocalNetworkPermissionsInstructions(e.toString()));
276 return <MDnsVmServiceDiscoveryResult>[];
277 }
278 }
279 }
280
281 Future<List<MDnsVmServiceDiscoveryResult>> _doPollingVmService(
282 MDnsClient client, {
283 String? applicationId,
284 int? deviceVmServicePort,
285 String? deviceName,
286 bool ipv6 = false,
287 bool useDeviceIPAsHost = false,
288 required Duration timeout,
289 bool quitOnFind = false,
290 }) async {
291 _logger.printTrace('Checking for advertised Dart VM Services...');
292 try {
293 await client.start();
294
295 final results = <MDnsVmServiceDiscoveryResult>[];
296
297 // uniqueDomainNames is used to track all domain names of Dart VM services
298 // It is later used in this function to determine whether or not to throw an error.
299 // We do not want to throw the error if it was unable to find any domain
300 // names because that indicates it may be a problem with mDNS, which has
301 // a separate error message in _checkForIPv4LinkLocal.
302 final uniqueDomainNames = <String>{};
303 // uniqueDomainNamesInResults is used to filter out duplicates with exactly
304 // the same domain name from the results.
305 final uniqueDomainNamesInResults = <String>{};
306
307 // Listen for mDNS connections until timeout.
308 final Stream<PtrResourceRecord> ptrResourceStream = client.lookup<PtrResourceRecord>(
309 ResourceRecordQuery.serverPointer(dartVmServiceName),
310 timeout: timeout,
311 );
312
313 await for (final PtrResourceRecord ptr in ptrResourceStream) {
314 uniqueDomainNames.add(ptr.domainName);
315
316 String? domainName;
317 if (applicationId != null) {
318 // If applicationId is set, only use records that match it
319 if (ptr.domainName.toLowerCase().startsWith(applicationId.toLowerCase())) {
320 domainName = ptr.domainName;
321 } else {
322 continue;
323 }
324 } else {
325 domainName = ptr.domainName;
326 }
327
328 // Result with same domain name was already found, skip it.
329 if (uniqueDomainNamesInResults.contains(domainName)) {
330 continue;
331 }
332
333 _logger.printTrace('Checking for available port on $domainName');
334 final List<SrvResourceRecord> srvRecords = await client
335 .lookup<SrvResourceRecord>(ResourceRecordQuery.service(domainName))
336 .toList();
337 if (srvRecords.isEmpty) {
338 continue;
339 }
340
341 // If more than one SrvResourceRecord found, it should just be a duplicate.
342 final SrvResourceRecord srvRecord = srvRecords.first;
343 if (srvRecords.length > 1) {
344 _logger.printWarning(
345 'Unexpectedly found more than one Dart VM Service report for $domainName '
346 '- using first one (${srvRecord.port}).',
347 );
348 }
349
350 // If deviceVmServicePort is set, only use records that match it
351 if (deviceVmServicePort != null && srvRecord.port != deviceVmServicePort) {
352 continue;
353 }
354
355 // If deviceName is set, only use records that match it
356 if (deviceName != null && !deviceNameMatchesTargetName(deviceName, srvRecord.target)) {
357 continue;
358 }
359
360 // Get the IP address of the device if using the IP as the host.
361 InternetAddress? ipAddress;
362 if (useDeviceIPAsHost) {
363 List<IPAddressResourceRecord> ipAddresses = await client
364 .lookup<IPAddressResourceRecord>(
365 ipv6
366 ? ResourceRecordQuery.addressIPv6(srvRecord.target)
367 : ResourceRecordQuery.addressIPv4(srvRecord.target),
368 )
369 .toList();
370 if (ipAddresses.isEmpty) {
371 throwToolExit('Did not find IP for service ${srvRecord.target}.');
372 }
373
374 // Filter out link-local addresses.
375 if (ipAddresses.length > 1) {
376 ipAddresses = ipAddresses
377 .where((IPAddressResourceRecord element) => !element.address.isLinkLocal)
378 .toList();
379 }
380
381 ipAddress = ipAddresses.first.address;
382 if (ipAddresses.length > 1) {
383 _logger.printWarning(
384 'Unexpectedly found more than one IP for Dart VM Service ${srvRecord.target} '
385 '- using first one ($ipAddress).',
386 );
387 }
388 }
389
390 _logger.printTrace('Checking for authentication code for $domainName');
391 final List<TxtResourceRecord> txt = await client
392 .lookup<TxtResourceRecord>(ResourceRecordQuery.text(domainName))
393 .toList();
394
395 var authCode = '';
396 if (txt.isNotEmpty) {
397 authCode = _getAuthCode(txt.first.text);
398 }
399 results.add(
400 MDnsVmServiceDiscoveryResult(domainName, srvRecord.port, authCode, ipAddress: ipAddress),
401 );
402 uniqueDomainNamesInResults.add(domainName);
403 if (quitOnFind) {
404 return results;
405 }
406 }
407
408 // If applicationId is set and quitOnFind is true and no results matching
409 // the applicationId were found but other results were found, throw an error.
410 if (applicationId != null && quitOnFind && results.isEmpty && uniqueDomainNames.isNotEmpty) {
411 var message = 'Did not find a Dart VM Service advertised for $applicationId';
412 if (deviceVmServicePort != null) {
413 message += ' on port $deviceVmServicePort';
414 }
415 throwToolExit('$message.');
416 }
417
418 return results;
419 } finally {
420 client.stop();
421 }
422 }
423
424 @visibleForTesting
425 bool deviceNameMatchesTargetName(String deviceName, String targetName) {
426 // Remove `.local` from the name along with any non-word, non-digit characters.
427 final cleanedNameRegex = RegExp(r'\.local|\W');
428 final String cleanedDeviceName = deviceName.trim().toLowerCase().replaceAll(
429 cleanedNameRegex,
430 '',
431 );
432 final String cleanedTargetName = targetName.toLowerCase().replaceAll(cleanedNameRegex, '');
433 return cleanedDeviceName == cleanedTargetName;
434 }
435
436 String _getAuthCode(String txtRecord) {
437 const authCodePrefix = 'authCode=';
438 final Iterable<String> matchingRecords = LineSplitter.split(
439 txtRecord,
440 ).where((String record) => record.startsWith(authCodePrefix));
441 if (matchingRecords.isEmpty) {
442 return '';
443 }
444 String authCode = matchingRecords.first.substring(authCodePrefix.length);
445 // The Dart VM Service currently expects a trailing '/' as part of the
446 // URI, otherwise an invalid authentication code response is given.
447 if (!authCode.endsWith('/')) {
448 authCode += '/';
449 }
450 return authCode;
451 }
452
453 /// Gets Dart VM Service Uri for `flutter attach`.
454 /// Executes an mDNS query and waits until a Dart VM Service is found.
455 ///
456 /// When [useDeviceIPAsHost] is true, it will use the device's IP as the
457 /// host and will not forward the port.
458 ///
459 /// Differs from [getVMServiceUriForLaunch] because it can search for any available Dart VM Service.
460 /// Since [applicationId] and [deviceVmservicePort] are optional, it can either look for any service
461 /// or a specific service matching [applicationId]/[deviceVmservicePort].
462 /// It may find more than one service, which will throw an error listing the found services.
463 Future<Uri?> getVMServiceUriForAttach(
464 String? applicationId,
465 Device device, {
466 bool usesIpv6 = false,
467 int? hostVmservicePort,
468 int? deviceVmservicePort,
469 bool useDeviceIPAsHost = false,
470 Duration timeout = const Duration(minutes: 10),
471 }) async {
472 final MDnsVmServiceDiscoveryResult? result = await queryForAttach(
473 applicationId: applicationId,
474 deviceVmservicePort: deviceVmservicePort,
475 ipv6: usesIpv6,
476 useDeviceIPAsHost: useDeviceIPAsHost,
477 timeout: timeout,
478 );
479 return _handleResult(
480 result,
481 device,
482 applicationId: applicationId,
483 deviceVmservicePort: deviceVmservicePort,
484 hostVmservicePort: hostVmservicePort,
485 usesIpv6: usesIpv6,
486 useDeviceIPAsHost: useDeviceIPAsHost,
487 );
488 }
489
490 /// Gets Dart VM Service Uri for `flutter run`.
491 /// Executes an mDNS query and waits until the Dart VM Service service is found.
492 ///
493 /// When [useDeviceIPAsHost] is true, it will use the device's IP as the
494 /// host and will not forward the port.
495 ///
496 /// Differs from [getVMServiceUriForAttach] because it only searches for a specific service.
497 /// This is enforced by [applicationId] being required and using either the
498 /// [deviceVmservicePort] or the [device]'s name to query.
499 Future<Uri?> getVMServiceUriForLaunch(
500 String applicationId,
501 Device device, {
502 bool usesIpv6 = false,
503 int? hostVmservicePort,
504 int? deviceVmservicePort,
505 bool useDeviceIPAsHost = false,
506 Duration timeout = const Duration(minutes: 10),
507 bool throwOnMissingLocalNetworkPermissionsError = true,
508 }) async {
509 final MDnsVmServiceDiscoveryResult? result = await queryForLaunch(
510 applicationId: applicationId,
511 deviceVmservicePort: deviceVmservicePort,
512 deviceName: deviceVmservicePort == null ? device.name : null,
513 ipv6: usesIpv6,
514 useDeviceIPAsHost: useDeviceIPAsHost,
515 timeout: timeout,
516 throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError,
517 );
518 return _handleResult(
519 result,
520 device,
521 applicationId: applicationId,
522 deviceVmservicePort: deviceVmservicePort,
523 hostVmservicePort: hostVmservicePort,
524 usesIpv6: usesIpv6,
525 useDeviceIPAsHost: useDeviceIPAsHost,
526 );
527 }
528
529 Future<Uri?> _handleResult(
530 MDnsVmServiceDiscoveryResult? result,
531 Device device, {
532 String? applicationId,
533 int? deviceVmservicePort,
534 int? hostVmservicePort,
535 bool usesIpv6 = false,
536 bool useDeviceIPAsHost = false,
537 }) async {
538 if (result == null) {
539 await _checkForIPv4LinkLocal(device);
540 return null;
541 }
542 final String host;
543
544 final InternetAddress? ipAddress = result.ipAddress;
545 if (useDeviceIPAsHost && ipAddress != null) {
546 host = ipAddress.address;
547 } else {
548 host = usesIpv6 ? InternetAddress.loopbackIPv6.address : InternetAddress.loopbackIPv4.address;
549 }
550 return buildVMServiceUri(
551 device,
552 host,
553 result.port,
554 hostVmservicePort,
555 result.authCode,
556 useDeviceIPAsHost,
557 );
558 }
559
560 // If there's not an ipv4 link local address in `NetworkInterfaces.list`,
561 // then request user interventions with a `printError()` if possible.
562 Future<void> _checkForIPv4LinkLocal(Device device) async {
563 _logger.printTrace(
564 'mDNS query failed. Checking for an interface with a ipv4 link local address.',
565 );
566 final List<NetworkInterface> interfaces = await listNetworkInterfaces(
567 includeLinkLocal: true,
568 type: InternetAddressType.IPv4,
569 );
570 if (_logger.isVerbose) {
571 _logInterfaces(interfaces);
572 }
573 final bool hasIPv4LinkLocal = interfaces.any(
574 (NetworkInterface interface) =>
575 interface.addresses.any((InternetAddress address) => address.isLinkLocal),
576 );
577 if (hasIPv4LinkLocal) {
578 _logger.printTrace('An interface with an ipv4 link local address was found.');
579 return;
580 }
581 final TargetPlatform targetPlatform = await device.targetPlatform;
582 switch (targetPlatform) {
583 case TargetPlatform.ios:
584 _analytics.send(
585 Event.appleUsageEvent(workflow: 'ios-mdns', parameter: 'no-ipv4-link-local'),
586 );
587 _logger.printError(
588 'The mDNS query for an attached iOS device failed. It may '
589 'be necessary to disable the "Personal Hotspot" on the device, and '
590 'to ensure that the "Disable unless needed" setting is unchecked '
591 'under System Preferences > Network > iPhone USB. '
592 'See https://github.com/flutter/flutter/issues/46698 for details.',
593 );
594 case TargetPlatform.android:
595 case TargetPlatform.android_arm:
596 case TargetPlatform.android_arm64:
597 case TargetPlatform.android_x64:
598 case TargetPlatform.darwin:
599 case TargetPlatform.fuchsia_arm64:
600 case TargetPlatform.fuchsia_x64:
601 case TargetPlatform.linux_arm64:
602 case TargetPlatform.linux_x64:
603 case TargetPlatform.tester:
604 case TargetPlatform.web_javascript:
605 case TargetPlatform.windows_x64:
606 case TargetPlatform.windows_arm64:
607 _logger.printTrace('No interface with an ipv4 link local address was found.');
608 case TargetPlatform.unsupported:
609 TargetPlatform.throwUnsupportedTarget();
610 }
611 }
612
613 void _logInterfaces(List<NetworkInterface> interfaces) {
614 for (final interface in interfaces) {
615 if (_logger.isVerbose) {
616 _logger.printTrace('Found interface "${interface.name}":');
617 for (final InternetAddress address in interface.addresses) {
618 final linkLocal = address.isLinkLocal ? 'link local' : '';
619 _logger.printTrace('\tBound address: "${address.address}" $linkLocal');
620 }
621 }
622 }
623 }
624}
625
626class MDnsVmServiceDiscoveryResult {
627 MDnsVmServiceDiscoveryResult(this.domainName, this.port, this.authCode, {this.ipAddress});
628 final String domainName;
629 final int port;
630 final String authCode;
631 final InternetAddress? ipAddress;
632}
633
634Future<Uri> buildVMServiceUri(
635 Device device,
636 String host,
637 int devicePort, [
638 int? hostVmservicePort,
639 String? authCode,
640 bool useDeviceIPAsHost = false,
641]) async {
642 var path = '/';
643 if (authCode != null) {
644 path = authCode;
645 }
646 // Not having a trailing slash can cause problems in some situations.
647 // Ensure that there's one present.
648 if (!path.endsWith('/')) {
649 path += '/';
650 }
651 hostVmservicePort ??= 0;
652
653 final int? actualHostPort;
654 if (useDeviceIPAsHost) {
655 // When using the device's IP as the host, port forwarding is not required
656 // so just use the device's port.
657 actualHostPort = devicePort;
658 } else {
659 actualHostPort = hostVmservicePort == 0
660 ? await device.portForwarder?.forward(devicePort)
661 : hostVmservicePort;
662 }
663 return Uri(scheme: 'http', host: host, port: actualHostPort, path: path);
664}
665