diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart index 82c60881d71f..843306abf6e7 100644 --- a/packages/flutter_tools/lib/src/commands/attach.dart +++ b/packages/flutter_tools/lib/src/commands/attach.dart @@ -16,7 +16,7 @@ import '../base/logger.dart'; import '../base/platform.dart'; import '../base/signals.dart'; import '../base/terminal.dart'; -import '../build_info.dart'; +import '../build_info.dart'; import '../commands/daemon.dart'; import '../compile.dart'; import '../daemon.dart'; @@ -24,6 +24,7 @@ import '../device.dart'; import '../device_port_forwarder.dart'; import '../fuchsia/fuchsia_device.dart'; import '../ios/devices.dart'; +import '../ios/iproxy.dart'; import '../ios/simulators.dart'; import '../macos/macos_ipad_device.dart'; import '../mdns_discovery.dart'; @@ -229,7 +230,7 @@ known, it can be explicitly provided to attach via the command-line, e.g. } if (debugPort != null && debugUri != null) { throwToolExit( - 'Either --debugPort or --debugUri can be provided, not both.'); + 'Either --debug-port or --debug-url can be provided, not both.'); } if (userIdentifier != null) { @@ -282,8 +283,9 @@ known, it can be explicitly provided to attach via the command-line, e.g. final String ipv6Loopback = InternetAddress.loopbackIPv6.address; final String ipv4Loopback = InternetAddress.loopbackIPv4.address; final String hostname = usesIpv6 ? ipv6Loopback : ipv4Loopback; + final bool isNetworkDevice = (device is IOSDevice) && device.interfaceType == IOSDeviceConnectionInterface.network; - if (debugPort == null && debugUri == null) { + if ((debugPort == null && debugUri == null) || isNetworkDevice) { if (device is FuchsiaDevice) { final String module = stringArgDeprecated('module')!; if (module == null) { @@ -303,16 +305,73 @@ known, it can be explicitly provided to attach via the command-line, e.g. rethrow; } } else if ((device is IOSDevice) || (device is IOSSimulator) || (device is MacOSDesignedForIPadDevice)) { - final Uri? uriFromMdns = - await MDnsObservatoryDiscovery.instance!.getObservatoryUri( - appId, - device, - usesIpv6: usesIpv6, - deviceVmservicePort: deviceVmservicePort, + // Protocol Discovery relies on logging. On iOS earlier than 13, logging is gathered using syslog. + // syslog is not available for iOS 13+. For iOS 13+, Protocol Discovery gathers logs from the VMService. + // Since we don't have access to the VMService yet, Protocol Discovery cannot be used for iOS 13+. + // Also, network devices must be found using mDNS and cannot use Protocol Discovery. + final bool compatibleWithProtocolDiscovery = (device is IOSDevice) && + device.majorSdkVersion < IOSDeviceLogReader.minimumUniversalLoggingSdkVersion && + !isNetworkDevice; + + _logger.printStatus('Waiting for a connection from Flutter on ${device.name}...'); + final Status discoveryStatus = _logger.startSpinner( + timeout: const Duration(seconds: 30), + slowWarningCallback: () { + // If relying on mDNS to find Dart VM Service, remind the user to allow local network permissions. + if (!compatibleWithProtocolDiscovery) { + return 'The Dart VM Service was not discovered after 30 seconds. This is taking much longer than expected...\n\n' + 'Click "Allow" to the prompt asking if you would like to find and connect devices on your local network. ' + 'If you selected "Don\'t Allow", you can turn it on in Settings > Your App Name > Local Network. ' + "If you don't see your app in the Settings, uninstall the app and rerun to see the prompt again.\n"; + } + + return 'The Dart VM Service was not discovered after 30 seconds. This is taking much longer than expected...\n'; + }, + ); + + int? devicePort; + if (debugPort != null) { + devicePort = debugPort; + } else if (debugUri != null) { + devicePort = debugUri?.port; + } else if (deviceVmservicePort != null) { + devicePort = deviceVmservicePort; + } + + final Future mDNSDiscoveryFuture = MDnsVmServiceDiscovery.instance!.getVMServiceUriForAttach( + appId, + device, + usesIpv6: usesIpv6, + isNetworkDevice: isNetworkDevice, + deviceVmservicePort: devicePort, + ); + + Future? protocolDiscoveryFuture; + if (compatibleWithProtocolDiscovery) { + final ProtocolDiscovery vmServiceDiscovery = ProtocolDiscovery.observatory( + device.getLogReader(), + portForwarder: device.portForwarder, + ipv6: ipv6!, + devicePort: devicePort, + hostPort: hostVmservicePort, + logger: _logger, ); - observatoryUri = uriFromMdns == null + protocolDiscoveryFuture = vmServiceDiscovery.uri; + } + + final Uri? foundUrl; + if (protocolDiscoveryFuture == null) { + foundUrl = await mDNSDiscoveryFuture; + } else { + foundUrl = await Future.any( + >[mDNSDiscoveryFuture, protocolDiscoveryFuture] + ); + } + discoveryStatus.stop(); + + observatoryUri = foundUrl == null ? null - : Stream.value(uriFromMdns).asBroadcastStream(); + : Stream.value(foundUrl).asBroadcastStream(); } // If MDNS discovery fails or we're not on iOS, fallback to ProtocolDiscovery. if (observatoryUri == null) { @@ -335,7 +394,7 @@ known, it can be explicitly provided to attach via the command-line, e.g. } else { observatoryUri = Stream .fromFuture( - buildObservatoryUri( + buildVMServiceUri( device, debugUri?.host ?? hostname, debugPort ?? debugUri!.port, diff --git a/packages/flutter_tools/lib/src/commands/drive.dart b/packages/flutter_tools/lib/src/commands/drive.dart index 0c469224a650..95945a0b1bf0 100644 --- a/packages/flutter_tools/lib/src/commands/drive.dart +++ b/packages/flutter_tools/lib/src/commands/drive.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:args/args.dart'; import 'package:meta/meta.dart'; import 'package:package_config/package_config_types.dart'; @@ -21,6 +22,8 @@ import '../dart/package_map.dart'; import '../device.dart'; import '../drive/drive_service.dart'; import '../globals.dart' as globals; +import '../ios/devices.dart'; +import '../ios/iproxy.dart'; import '../resident_runner.dart'; import '../runner/flutter_command.dart' show FlutterCommandCategory, FlutterCommandResult, FlutterOptions; import '../web/web_device.dart'; @@ -203,6 +206,27 @@ class DriveCommand extends RunCommandBase { @override bool get cachePubGet => false; + String? get applicationBinaryPath => stringArgDeprecated(FlutterOptions.kUseApplicationBinary); + + Future get targetedDevice async { + return findTargetDevice(includeUnsupportedDevices: applicationBinaryPath == null); + } + + // Network devices need `publish-port` to be enabled because it requires mDNS. + // If the flag wasn't provided as an actual argument and it's a network device, + // change it to be enabled. + @override + Future get disablePortPublication async { + final ArgResults? localArgResults = argResults; + final Device? device = await targetedDevice; + final bool isNetworkDevice = device is IOSDevice && device.interfaceType == IOSDeviceConnectionInterface.network; + if (isNetworkDevice && localArgResults != null && !localArgResults.wasParsed('publish-port')) { + _logger.printTrace('Network device is being used. Changing `publish-port` to be enabled.'); + return false; + } + return !boolArgDeprecated('publish-port'); + } + @override Future validateCommand() async { if (userIdentifier != null) { @@ -223,8 +247,7 @@ class DriveCommand extends RunCommandBase { if (await _fileSystem.type(testFile) != FileSystemEntityType.file) { throwToolExit('Test file not found: $testFile'); } - final String? applicationBinaryPath = stringArgDeprecated(FlutterOptions.kUseApplicationBinary); - final Device? device = await findTargetDevice(includeUnsupportedDevices: applicationBinaryPath == null); + final Device? device = await targetedDevice; if (device == null) { throwToolExit(null); } diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart index be9a9a272cc5..37c7dc495740 100644 --- a/packages/flutter_tools/lib/src/commands/run.dart +++ b/packages/flutter_tools/lib/src/commands/run.dart @@ -254,7 +254,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment purgePersistentCache: purgePersistentCache, deviceVmServicePort: deviceVmservicePort, hostVmServicePort: hostVmservicePort, - disablePortPublication: disablePortPublication, + disablePortPublication: await disablePortPublication, ddsPort: ddsPort, devToolsServerAddress: devToolsServerAddress, verboseSystemLogs: boolArgDeprecated('verbose-system-logs'), diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart index ba960c1b4158..fffa29da4ede 100644 --- a/packages/flutter_tools/lib/src/context_runner.dart +++ b/packages/flutter_tools/lib/src/context_runner.dart @@ -275,7 +275,7 @@ Future runInContext( featureFlags: featureFlags, platform: globals.platform, ), - MDnsObservatoryDiscovery: () => MDnsObservatoryDiscovery( + MDnsVmServiceDiscovery: () => MDnsVmServiceDiscovery( logger: globals.logger, flutterUsage: globals.flutterUsage, ), diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index 67e6cab36cb9..fbd2686eeaeb 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -17,6 +17,7 @@ import 'base/utils.dart'; import 'build_info.dart'; import 'devfs.dart'; import 'device_port_forwarder.dart'; +import 'ios/iproxy.dart'; import 'project.dart'; import 'vmservice.dart'; @@ -917,7 +918,13 @@ class DebuggingOptions { /// * https://github.com/dart-lang/sdk/blob/main/sdk/lib/html/doc/NATIVE_NULL_ASSERTIONS.md final bool nativeNullAssertions; - List getIOSLaunchArguments(EnvironmentType environmentType, String? route, Map platformArgs) { + List getIOSLaunchArguments( + EnvironmentType environmentType, + String? route, + Map platformArgs, { + bool ipv6 = false, + IOSDeviceConnectionInterface interfaceType = IOSDeviceConnectionInterface.none + }) { final String dartVmFlags = computeDartVmFlags(this); return [ if (enableDartProfiling) '--enable-dart-profiling', @@ -954,6 +961,9 @@ class DebuggingOptions { // Use the suggested host port. if (environmentType == EnvironmentType.simulator && hostVmServicePort != null) '--observatory-port=$hostVmServicePort', + // Tell the observatory to listen on all interfaces, don't restrict to the loopback. + if (interfaceType == IOSDeviceConnectionInterface.network) + '--observatory-host=${ipv6 ? '::0' : '0.0.0.0'}', ]; } diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index b1606e161326..c58de37a6bda 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -9,6 +9,7 @@ import 'package:process/process.dart'; import 'package:vm_service/vm_service.dart' as vm_service; import '../application_package.dart'; +import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; @@ -21,6 +22,7 @@ import '../device.dart'; import '../device_port_forwarder.dart'; import '../globals.dart' as globals; import '../macos/xcdevice.dart'; +import '../mdns_discovery.dart'; import '../project.dart'; import '../protocol_discovery.dart'; import '../vmservice.dart'; @@ -189,15 +191,6 @@ class IOSDevice extends Device { return majorVersionString != null ? int.tryParse(majorVersionString) ?? 0 : 0; } - @override - bool get supportsHotReload => interfaceType == IOSDeviceConnectionInterface.usb; - - @override - bool get supportsHotRestart => interfaceType == IOSDeviceConnectionInterface.usb; - - @override - bool get supportsFlutterExit => interfaceType == IOSDeviceConnectionInterface.usb; - @override final String name; @@ -318,7 +311,11 @@ class IOSDevice extends Device { @visibleForTesting Duration? discoveryTimeout, }) async { String? packageId; - + if (interfaceType == IOSDeviceConnectionInterface.network && + debuggingOptions.debuggingEnabled && + debuggingOptions.disablePortPublication) { + throwToolExit('Cannot start app on wirelessly tethered iOS device. Try running again with the --publish-port flag'); + } if (!prebuiltApplication) { _logger.printTrace('Building ${package.name} for $id'); @@ -353,8 +350,10 @@ class IOSDevice extends Device { EnvironmentType.physical, route, platformArgs, + ipv6: ipv6, + interfaceType: interfaceType, ); - final Status installStatus = _logger.startProgress( + Status startAppStatus = _logger.startProgress( 'Installing and launching...', ); try { @@ -379,9 +378,10 @@ class IOSDevice extends Device { deviceLogReader.debuggerStream = iosDeployDebugger; } } + // Don't port foward if debugging with a network device. observatoryDiscovery = ProtocolDiscovery.observatory( deviceLogReader, - portForwarder: portForwarder, + portForwarder: interfaceType == IOSDeviceConnectionInterface.network ? null : portForwarder, hostPort: debuggingOptions.hostVmServicePort, devicePort: debuggingOptions.deviceVmServicePort, ipv6: ipv6, @@ -412,12 +412,59 @@ class IOSDevice extends Device { return LaunchResult.succeeded(); } - _logger.printTrace('Application launched on the device. Waiting for observatory url.'); - final Timer timer = Timer(discoveryTimeout ?? const Duration(seconds: 30), () { - _logger.printError('iOS Observatory not discovered after 30 seconds. This is taking much longer than expected...'); - iosDeployDebugger?.pauseDumpBacktraceResume(); + _logger.printTrace('Application launched on the device. Waiting for Dart VM Service url.'); + + final int defaultTimeout = interfaceType == IOSDeviceConnectionInterface.network ? 45 : 30; + final Timer timer = Timer(discoveryTimeout ?? Duration(seconds: defaultTimeout), () { + _logger.printError('The Dart VM Service was not discovered after $defaultTimeout seconds. This is taking much longer than expected...'); + + // If debugging with a wireless device and the timeout is reached, remind the + // user to allow local network permissions. + if (interfaceType == IOSDeviceConnectionInterface.network) { + _logger.printError( + '\nClick "Allow" to the prompt asking if you would like to find and connect devices on your local network. ' + 'This is required for wireless debugging. If you selected "Don\'t Allow", ' + 'you can turn it on in Settings > Your App Name > Local Network. ' + "If you don't see your app in the Settings, uninstall the app and rerun to see the prompt again." + ); + } else { + iosDeployDebugger?.pauseDumpBacktraceResume(); + } }); - final Uri? localUri = await observatoryDiscovery?.uri; + + Uri? localUri; + if (interfaceType == IOSDeviceConnectionInterface.network) { + // Wait for Dart VM Service to start up. + final Uri? serviceURL = await observatoryDiscovery?.uri; + if (serviceURL == null) { + await iosDeployDebugger?.stopAndDumpBacktrace(); + return LaunchResult.failed(); + } + + // If Dart VM Service URL with the device IP is not found within 5 seconds, + // change the status message to prompt users to click Allow. Wait 5 seconds because it + // should only show this message if they have not already approved the permissions. + // MDnsVmServiceDiscovery usually takes less than 5 seconds to find it. + final Timer mDNSLookupTimer = Timer(const Duration(seconds: 5), () { + startAppStatus.stop(); + startAppStatus = _logger.startProgress( + 'Waiting for approval of local network permissions...', + ); + }); + + // Get Dart VM Service URL with the device IP as the host. + localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( + packageId, + this, + usesIpv6: ipv6, + deviceVmservicePort: serviceURL.port, + isNetworkDevice: true, + ); + + mDNSLookupTimer.cancel(); + } else { + localUri = await observatoryDiscovery?.uri; + } timer.cancel(); if (localUri == null) { await iosDeployDebugger?.stopAndDumpBacktrace(); @@ -429,7 +476,7 @@ class IOSDevice extends Device { _logger.printError(e.message); return LaunchResult.failed(); } finally { - installStatus.stop(); + startAppStatus.stop(); } } @@ -569,7 +616,6 @@ String decodeSyslog(String line) { } } -@visibleForTesting class IOSDeviceLogReader extends DeviceLogReader { IOSDeviceLogReader._( this._iMobileDevice, diff --git a/packages/flutter_tools/lib/src/macos/xcdevice.dart b/packages/flutter_tools/lib/src/macos/xcdevice.dart index c6bde74e5376..fae5186e5f0e 100644 --- a/packages/flutter_tools/lib/src/macos/xcdevice.dart +++ b/packages/flutter_tools/lib/src/macos/xcdevice.dart @@ -227,8 +227,16 @@ class XCDevice { /// [timeout] defaults to 2 seconds. Future> getAvailableIOSDevices({ Duration? timeout }) async { + Status? loadDevicesStatus; + if (timeout != null && timeout.inSeconds > 2) { + loadDevicesStatus = _logger.startProgress( + 'Loading devices...', + ); + } final List? allAvailableDevices = await _getAllDevices(timeout: timeout ?? const Duration(seconds: 2)); - + if (loadDevicesStatus != null) { + loadDevicesStatus.stop(); + } if (allAvailableDevices == null) { return const []; } @@ -305,12 +313,6 @@ class XCDevice { final IOSDeviceConnectionInterface interface = _interfaceType(device); - // Only support USB devices, skip "network" interface (Xcode > Window > Devices and Simulators > Connect via network). - // TODO(jmagman): Remove this check once wirelessly detected devices can be observed and attached, https://github.com/flutter/flutter/issues/15072. - if (interface != IOSDeviceConnectionInterface.usb) { - continue; - } - String? sdkVersion = _sdkVersion(device); if (sdkVersion != null) { diff --git a/packages/flutter_tools/lib/src/mdns_discovery.dart b/packages/flutter_tools/lib/src/mdns_discovery.dart index a1d0717e1d2f..d466a83e7ca5 100644 --- a/packages/flutter_tools/lib/src/mdns_discovery.dart +++ b/packages/flutter_tools/lib/src/mdns_discovery.dart @@ -13,161 +13,427 @@ import 'build_info.dart'; import 'device.dart'; import 'reporting/reporting.dart'; -/// A wrapper around [MDnsClient] to find a Dart observatory instance. -class MDnsObservatoryDiscovery { - /// Creates a new [MDnsObservatoryDiscovery] object. +/// A wrapper around [MDnsClient] to find a Dart VM Service instance. +class MDnsVmServiceDiscovery { + /// Creates a new [MDnsVmServiceDiscovery] object. /// /// The [_client] parameter will be defaulted to a new [MDnsClient] if null. - /// The [applicationId] parameter may be null, and can be used to - /// automatically select which application to use if multiple are advertising - /// Dart observatory ports. - MDnsObservatoryDiscovery({ + MDnsVmServiceDiscovery({ MDnsClient? mdnsClient, + MDnsClient? preliminaryMDnsClient, required Logger logger, required Usage flutterUsage, - }): _client = mdnsClient ?? MDnsClient(), - _logger = logger, - _flutterUsage = flutterUsage; + }) : _client = mdnsClient ?? MDnsClient(), + _preliminaryClient = preliminaryMDnsClient, + _logger = logger, + _flutterUsage = flutterUsage; final MDnsClient _client; + + // Used when discovering VM services with `queryForAttach` to do a preliminary + // check for already running services so that results are not cached in _client. + final MDnsClient? _preliminaryClient; + final Logger _logger; final Usage _flutterUsage; @visibleForTesting - static const String dartObservatoryName = '_dartobservatory._tcp.local'; + static const String dartVmServiceName = '_dartobservatory._tcp.local'; - static MDnsObservatoryDiscovery? get instance => context.get(); + static MDnsVmServiceDiscovery? get instance => context.get(); - /// Executes an mDNS query for a Dart Observatory. + /// Executes an mDNS query for Dart VM Services. + /// Checks for services that have already been launched. + /// If none are found, it will listen for new services to become active + /// and return the first it finds that match the parameters. /// /// The [applicationId] parameter may be used to specify which application /// to find. For Android, it refers to the package name; on iOS, it refers to /// the bundle ID. /// - /// If it is not null, this method will find the port and authentication code - /// of the Dart Observatory for that application. If it cannot find a Dart - /// Observatory matching that application identifier, it will call - /// [throwToolExit]. + /// The [deviceVmservicePort] parameter may be used to specify which port + /// to find. + /// + /// The [isNetworkDevice] parameter flags whether to get the device IP + /// and the [ipv6] parameter flags whether to get an iPv6 address + /// (otherwise it will get iPv4). + /// + /// The [timeout] parameter determines how long to continue to wait for + /// services to become active. /// - /// If it is null and there are multiple ports available, the user will be - /// prompted with a list of available observatory ports and asked to select - /// one. + /// If [applicationId] is not null, this method will find the port and authentication code + /// of the Dart VM Service for that application. If it cannot find a service matching + /// that application identifier after the [timeout], it will call [throwToolExit]. /// - /// If it is null and there is only one available instance of Observatory, - /// it will return that instance's information regardless of what application - /// the Observatory instance is for. + /// If [applicationId] is null and there are multiple Dart VM Services available, + /// the user will be prompted with a list of available services with the respective + /// app-id and device-vmservice-port to use and asked to select one. + /// + /// If it is null and there is only one available or it's the first found instance + /// of Dart VM Service, it will return that instance's information regardless of + /// what application the service instance is for. @visibleForTesting - Future query({String? applicationId, int? deviceVmservicePort}) async { - _logger.printTrace('Checking for advertised Dart observatories...'); - try { - await _client.start(); - final List pointerRecords = await _client - .lookup( - ResourceRecordQuery.serverPointer(dartObservatoryName), - ) - .toList(); - if (pointerRecords.isEmpty) { - _logger.printTrace('No pointer records found.'); - return null; + Future queryForAttach({ + String? applicationId, + int? deviceVmservicePort, + bool ipv6 = false, + bool isNetworkDevice = false, + Duration timeout = const Duration(minutes: 10), + }) async { + // Poll for 5 seconds to see if there are already services running. + // Use a new instance of MDnsClient so results don't get cached in _client. + // If no results are found, poll for a longer duration to wait for connections. + // If more than 1 result is found, throw an error since it can't be determined which to pick. + // If only one is found, return it. + final List results = await _pollingVmService( + _preliminaryClient ?? MDnsClient(), + applicationId: applicationId, + deviceVmservicePort: deviceVmservicePort, + ipv6: ipv6, + isNetworkDevice: isNetworkDevice, + timeout: const Duration(seconds: 5), + ); + if (results.isEmpty) { + return firstMatchingVmService( + _client, + applicationId: applicationId, + deviceVmservicePort: deviceVmservicePort, + ipv6: ipv6, + isNetworkDevice: isNetworkDevice, + timeout: timeout, + ); + } else if (results.length > 1) { + final StringBuffer buffer = StringBuffer(); + buffer.writeln('There are multiple Dart VM Services available.'); + buffer.writeln('Rerun this command with one of the following passed in as the app-id and device-vmservice-port:'); + buffer.writeln(); + for (final MDnsVmServiceDiscoveryResult result in results) { + buffer.writeln( + ' flutter attach --app-id "${result.domainName.replaceAll('.$dartVmServiceName', '')}" --device-vmservice-port ${result.port}'); } - // We have no guarantee that we won't get multiple hits from the same - // service on this. - final Set uniqueDomainNames = pointerRecords - .map((PtrResourceRecord record) => record.domainName) - .toSet(); - - String? domainName; - if (applicationId != null) { - for (final String name in uniqueDomainNames) { - if (name.toLowerCase().startsWith(applicationId.toLowerCase())) { - domainName = name; + throwToolExit(buffer.toString()); + } + return results.first; + } + + /// Executes an mDNS query for Dart VM Services. + /// Listens for new services to become active and returns the first it finds that + /// match the parameters. + /// + /// The [applicationId] parameter must be set to specify which application + /// to find. For Android, it refers to the package name; on iOS, it refers to + /// the bundle ID. + /// + /// The [deviceVmservicePort] parameter must be set to specify which port + /// to find. + /// + /// [applicationId] and [deviceVmservicePort] are required for launch so that + /// if multiple flutter apps are running on different devices, it will + /// only match with the device running the desired app. + /// + /// The [isNetworkDevice] parameter flags whether to get the device IP + /// and the [ipv6] parameter flags whether to get an iPv6 address + /// (otherwise it will get iPv4). + /// + /// The [timeout] parameter determines how long to continue to wait for + /// services to become active. + /// + /// If a Dart VM Service matching the [applicationId] and [deviceVmservicePort] + /// cannot be found after the [timeout], it will call [throwToolExit]. + @visibleForTesting + Future queryForLaunch({ + required String applicationId, + required int deviceVmservicePort, + bool ipv6 = false, + bool isNetworkDevice = false, + Duration timeout = const Duration(minutes: 10), + }) async { + // Query for a specific application and device port. + return firstMatchingVmService( + _client, + applicationId: applicationId, + deviceVmservicePort: deviceVmservicePort, + ipv6: ipv6, + isNetworkDevice: isNetworkDevice, + timeout: timeout, + ); + } + + /// Polls for Dart VM Services and returns the first it finds that match + /// the [applicationId]/[deviceVmservicePort] (if applicable). + /// Returns null if no results are found. + @visibleForTesting + Future firstMatchingVmService( + MDnsClient client, { + String? applicationId, + int? deviceVmservicePort, + bool ipv6 = false, + bool isNetworkDevice = false, + Duration timeout = const Duration(minutes: 10), + }) async { + final List results = await _pollingVmService( + client, + applicationId: applicationId, + deviceVmservicePort: deviceVmservicePort, + ipv6: ipv6, + isNetworkDevice: isNetworkDevice, + timeout: timeout, + quitOnFind: true, + ); + if (results.isEmpty) { + return null; + } + return results.first; + } + + Future> _pollingVmService( + MDnsClient client, { + String? applicationId, + int? deviceVmservicePort, + bool ipv6 = false, + bool isNetworkDevice = false, + required Duration timeout, + bool quitOnFind = false, + }) async { + _logger.printTrace('Checking for advertised Dart VM Services...'); + try { + await client.start(); + + final List results = + []; + final Set uniqueDomainNames = {}; + + // Listen for mDNS connections until timeout. + final Stream ptrResourceStream = client.lookup( + ResourceRecordQuery.serverPointer(dartVmServiceName), + timeout: timeout + ); + await for (final PtrResourceRecord ptr in ptrResourceStream) { + uniqueDomainNames.add(ptr.domainName); + + String? domainName; + if (applicationId != null) { + // If applicationId is set, only use records that match it + if (ptr.domainName.toLowerCase().startsWith(applicationId.toLowerCase())) { + domainName = ptr.domainName; + } else { + continue; + } + } else { + domainName = ptr.domainName; + } + + _logger.printTrace('Checking for available port on $domainName'); + final List srvRecords = await client + .lookup( + ResourceRecordQuery.service(domainName), + ) + .toList(); + if (srvRecords.isEmpty) { + continue; + } + + // If more than one SrvResourceRecord found, it should just be a duplicate. + final SrvResourceRecord srvRecord = srvRecords.first; + if (srvRecords.length > 1) { + _logger.printWarning( + 'Unexpectedly found more than one Dart VM Service report for $domainName ' + '- using first one (${srvRecord.port}).'); + } + + // If deviceVmservicePort is set, only use records that match it + if (deviceVmservicePort != null && srvRecord.port != deviceVmservicePort) { + continue; + } + + // Get the IP address of the service if using a network device. + InternetAddress? ipAddress; + if (isNetworkDevice) { + List ipAddresses = await client + .lookup( + ipv6 + ? ResourceRecordQuery.addressIPv6(srvRecord.target) + : ResourceRecordQuery.addressIPv4(srvRecord.target), + ) + .toList(); + if (ipAddresses.isEmpty) { + throwToolExit('Did not find IP for service ${srvRecord.target}.'); + } + + // Filter out link-local addresses. + if (ipAddresses.length > 1) { + ipAddresses = ipAddresses.where((IPAddressResourceRecord element) => !element.address.isLinkLocal).toList(); + } + + ipAddress = ipAddresses.first.address; + if (ipAddresses.length > 1) { + _logger.printWarning( + 'Unexpectedly found more than one IP for Dart VM Service ${srvRecord.target} ' + '- using first one ($ipAddress).'); + } + } + + _logger.printTrace('Checking for authentication code for $domainName'); + final List txt = await client + .lookup( + ResourceRecordQuery.text(domainName), + ) + .toList(); + if (txt == null || txt.isEmpty) { + results.add(MDnsVmServiceDiscoveryResult(domainName, srvRecord.port, '')); + if (quitOnFind) { + return results; + } + continue; + } + const String authCodePrefix = 'authCode='; + String? raw; + for (final String record in txt.first.text.split('\n')) { + if (record.startsWith(authCodePrefix)) { + raw = record; break; } } - if (domainName == null) { - throwToolExit('Did not find a observatory port advertised for $applicationId.'); + if (raw == null) { + results.add(MDnsVmServiceDiscoveryResult(domainName, srvRecord.port, '')); + if (quitOnFind) { + return results; + } + continue; } - } else if (uniqueDomainNames.length > 1) { - final StringBuffer buffer = StringBuffer(); - buffer.writeln('There are multiple observatory ports available.'); - buffer.writeln('Rerun this command with one of the following passed in as the appId:'); - buffer.writeln(); - for (final String uniqueDomainName in uniqueDomainNames) { - buffer.writeln(' flutter attach --app-id ${uniqueDomainName.replaceAll('.$dartObservatoryName', '')}'); + String authCode = raw.substring(authCodePrefix.length); + // The Dart VM Service currently expects a trailing '/' as part of the + // URI, otherwise an invalid authentication code response is given. + if (!authCode.endsWith('/')) { + authCode += '/'; } - throwToolExit(buffer.toString()); - } else { - domainName = pointerRecords[0].domainName; - } - _logger.printTrace('Checking for available port on $domainName'); - // Here, if we get more than one, it should just be a duplicate. - final List srv = await _client - .lookup( - ResourceRecordQuery.service(domainName), - ) - .toList(); - if (srv.isEmpty) { - return null; - } - if (srv.length > 1) { - _logger.printWarning('Unexpectedly found more than one observatory report for $domainName ' - '- using first one (${srv.first.port}).'); - } - _logger.printTrace('Checking for authentication code for $domainName'); - final List txt = await _client - .lookup( - ResourceRecordQuery.text(domainName), - ) - .toList(); - if (txt == null || txt.isEmpty) { - return MDnsObservatoryDiscoveryResult(srv.first.port, ''); - } - const String authCodePrefix = 'authCode='; - String? raw; - for (final String record in txt.first.text.split('\n')) { - if (record.startsWith(authCodePrefix)) { - raw = record; - break; + + results.add(MDnsVmServiceDiscoveryResult( + domainName, + srvRecord.port, + authCode, + ipAddress: ipAddress + )); + if (quitOnFind) { + return results; } } - if (raw == null) { - return MDnsObservatoryDiscoveryResult(srv.first.port, ''); - } - String authCode = raw.substring(authCodePrefix.length); - // The Observatory currently expects a trailing '/' as part of the - // URI, otherwise an invalid authentication code response is given. - if (!authCode.endsWith('/')) { - authCode += '/'; + + // If applicationId is set and quitOnFind is true and no results matching + // the applicationId were found but other results were found, throw an error. + if (applicationId != null && + quitOnFind && + results.isEmpty && + uniqueDomainNames.isNotEmpty) { + String message = 'Did not find a Dart VM Service advertised for $applicationId'; + if (deviceVmservicePort != null) { + message += ' on port $deviceVmservicePort'; + } + throwToolExit('$message.'); } - return MDnsObservatoryDiscoveryResult(srv.first.port, authCode); + + return results; } finally { - _client.stop(); + client.stop(); } } - Future getObservatoryUri(String? applicationId, Device device, { + /// Gets Dart VM Service Uri for `flutter attach`. + /// Executes an mDNS query and waits until a Dart VM Service is found. + /// + /// Differs from `getVMServiceUriForLaunch` because it can search for any available Dart VM Service. + /// Since [applicationId] and [deviceVmservicePort] are optional, it can either look for any service + /// or a specific service matching [applicationId]/[deviceVmservicePort]. + /// It may find more than one service, which will throw an error listing the found services. + Future getVMServiceUriForAttach( + String? applicationId, + Device device, { bool usesIpv6 = false, int? hostVmservicePort, int? deviceVmservicePort, + bool isNetworkDevice = false, + Duration timeout = const Duration(minutes: 10), + }) async { + final MDnsVmServiceDiscoveryResult? result = await queryForAttach( + applicationId: applicationId, + deviceVmservicePort: deviceVmservicePort, + ipv6: usesIpv6, + isNetworkDevice: isNetworkDevice, + timeout: timeout, + ); + return _handleResult( + result, + device, + applicationId: applicationId, + deviceVmservicePort: deviceVmservicePort, + hostVmservicePort: hostVmservicePort, + usesIpv6: usesIpv6, + isNetworkDevice: isNetworkDevice + ); + } + + /// Gets Dart VM Service Uri for `flutter run`. + /// Executes an mDNS query and waits until the Dart VM Service service is found. + /// + /// Differs from `getVMServiceUriForAttach` because it only searches for a specific service. + /// This is enforced by [applicationId] and [deviceVmservicePort] being required. + Future getVMServiceUriForLaunch( + String applicationId, + Device device, { + bool usesIpv6 = false, + int? hostVmservicePort, + required int deviceVmservicePort, + bool isNetworkDevice = false, + Duration timeout = const Duration(minutes: 10), }) async { - final MDnsObservatoryDiscoveryResult? result = await query( + final MDnsVmServiceDiscoveryResult? result = await queryForLaunch( applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, + ipv6: usesIpv6, + isNetworkDevice: isNetworkDevice, + timeout: timeout, ); + return _handleResult( + result, + device, + applicationId: applicationId, + deviceVmservicePort: deviceVmservicePort, + hostVmservicePort: hostVmservicePort, + usesIpv6: usesIpv6, + isNetworkDevice: isNetworkDevice + ); + } + + Future _handleResult( + MDnsVmServiceDiscoveryResult? result, + Device device, { + String? applicationId, + int? deviceVmservicePort, + int? hostVmservicePort, + bool usesIpv6 = false, + bool isNetworkDevice = false, + }) async { if (result == null) { await _checkForIPv4LinkLocal(device); return null; } + final String host; - final String host = usesIpv6 + final InternetAddress? ipAddress = result.ipAddress; + if (isNetworkDevice && ipAddress != null) { + host = ipAddress.address; + } else { + host = usesIpv6 ? InternetAddress.loopbackIPv6.address : InternetAddress.loopbackIPv4.address; - return buildObservatoryUri( + } + return buildVMServiceUri( device, host, result.port, hostVmservicePort, result.authCode, + isNetworkDevice, ); } @@ -236,18 +502,26 @@ class MDnsObservatoryDiscovery { } } -class MDnsObservatoryDiscoveryResult { - MDnsObservatoryDiscoveryResult(this.port, this.authCode); +class MDnsVmServiceDiscoveryResult { + MDnsVmServiceDiscoveryResult( + this.domainName, + this.port, + this.authCode, { + this.ipAddress + }); + final String domainName; final int port; final String authCode; + final InternetAddress? ipAddress; } -Future buildObservatoryUri( +Future buildVMServiceUri( Device device, String host, int devicePort, [ int? hostVmservicePort, String? authCode, + bool isNetworkDevice = false, ]) async { String path = '/'; if (authCode != null) { @@ -259,8 +533,16 @@ Future buildObservatoryUri( path += '/'; } hostVmservicePort ??= 0; - final int? actualHostPort = hostVmservicePort == 0 ? + + final int? actualHostPort; + if (isNetworkDevice) { + // When debugging with a network device, port forwarding is not required + // so just use the device's port. + actualHostPort = devicePort; + } else { + actualHostPort = hostVmservicePort == 0 ? await device.portForwarder?.forward(devicePort) : hostVmservicePort; + } return Uri(scheme: 'http', host: host, port: actualHostPort, path: path); } diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index d0a963c94f37..fd9c3ed67209 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -569,7 +569,7 @@ abstract class FlutterCommand extends Command { ); } - bool get disablePortPublication => !boolArgDeprecated('publish-port'); + Future get disablePortPublication async => !boolArgDeprecated('publish-port'); void usesIpv6Flag({required bool verboseHelp}) { argParser.addFlag(ipv6Flag, diff --git a/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart index a725c0b1b1f8..42d91c6bf877 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart @@ -23,6 +23,7 @@ import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/device_port_forwarder.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; import 'package:flutter_tools/src/ios/devices.dart'; +import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/macos/macos_ipad_device.dart'; import 'package:flutter_tools/src/mdns_discovery.dart'; import 'package:flutter_tools/src/project.dart'; @@ -83,6 +84,7 @@ void main() { group('with one device and no specified target file', () { const int devicePort = 499; const int hostPort = 42; + final int future = DateTime.now().add(const Duration(days: 1)).millisecondsSinceEpoch; late FakeDeviceLogReader fakeLogReader; late RecordingPortForwarder portForwarder; @@ -102,17 +104,17 @@ void main() { fakeLogReader.dispose(); }); - testUsingContext('succeeds with iOS device', () async { + testUsingContext('succeeds with iOS device with protocol discovery', () async { final FakeIOSDevice device = FakeIOSDevice( logReader: fakeLogReader, portForwarder: portForwarder, + majorSdkVersion: 12, onGetLogReader: () { fakeLogReader.addLine('Foo'); fakeLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:$devicePort'); return fakeLogReader; }, ); - testDeviceManager.devices = [device]; final Completer completer = Completer(); final StreamSubscription loggerSubscription = logger.stream.listen((String message) { @@ -121,7 +123,20 @@ void main() { completer.complete(); } }); - final Future task = createTestCommandRunner(AttachCommand( + final FakeHotRunner hotRunner = FakeHotRunner(); + hotRunner.onAttach = ( + Completer? connectionInfoCompleter, + Completer? appStartedCompleter, + bool allowExistingDdsInstance, + bool enableDevTools, + ) async => 0; + hotRunner.exited = false; + hotRunner.isWaitingForObservatory = false; + final FakeHotRunnerFactory hotRunnerFactory = FakeHotRunnerFactory() + ..hotRunner = hotRunner; + + await createTestCommandRunner(AttachCommand( + hotRunnerFactory: hotRunnerFactory, artifacts: artifacts, stdio: stdio, logger: logger, @@ -137,15 +152,309 @@ void main() { expect(portForwarder.hostPort, hostPort); await fakeLogReader.dispose(); - await expectLoggerInterruptEndsTask(task, logger); await loggerSubscription.cancel(); }, overrides: { FileSystem: () => testFileSystem, ProcessManager: () => FakeProcessManager.any(), Logger: () => logger, DeviceManager: () => testDeviceManager, - MDnsObservatoryDiscovery: () => MDnsObservatoryDiscovery( + MDnsVmServiceDiscovery: () => MDnsVmServiceDiscovery( + mdnsClient: FakeMDnsClient([], >{}), + preliminaryMDnsClient: FakeMDnsClient([], >{}), + logger: logger, + flutterUsage: TestUsage(), + ), + }); + + testUsingContext('succeeds with iOS device with mDNS', () async { + final FakeIOSDevice device = FakeIOSDevice( + logReader: fakeLogReader, + portForwarder: portForwarder, + majorSdkVersion: 16, + onGetLogReader: () { + fakeLogReader.addLine('Foo'); + fakeLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:$devicePort'); + return fakeLogReader; + }, + ); + testDeviceManager.devices = [device]; + final FakeHotRunner hotRunner = FakeHotRunner(); + hotRunner.onAttach = ( + Completer? connectionInfoCompleter, + Completer? appStartedCompleter, + bool allowExistingDdsInstance, + bool enableDevTools, + ) async => 0; + hotRunner.exited = false; + hotRunner.isWaitingForObservatory = false; + final FakeHotRunnerFactory hotRunnerFactory = FakeHotRunnerFactory() + ..hotRunner = hotRunner; + + await createTestCommandRunner(AttachCommand( + hotRunnerFactory: hotRunnerFactory, + artifacts: artifacts, + stdio: stdio, + logger: logger, + terminal: terminal, + signals: signals, + platform: platform, + processInfo: processInfo, + fileSystem: testFileSystem, + )).run(['attach']); + await fakeLogReader.dispose(); + + expect(portForwarder.devicePort, devicePort); + expect(portForwarder.hostPort, hostPort); + expect(hotRunnerFactory.devices, hasLength(1)); + final FlutterDevice flutterDevice = hotRunnerFactory.devices.first; + final Uri? observatoryUri = await flutterDevice.observatoryUris?.first; + expect(observatoryUri.toString(), 'http://127.0.0.1:$hostPort/xyz/'); + }, overrides: { + FileSystem: () => testFileSystem, + ProcessManager: () => FakeProcessManager.any(), + Logger: () => logger, + DeviceManager: () => testDeviceManager, + MDnsVmServiceDiscovery: () => MDnsVmServiceDiscovery( mdnsClient: FakeMDnsClient([], >{}), + preliminaryMDnsClient: FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), + ], + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: devicePort, weight: 1, priority: 1, target: 'appId'), + ], + }, + txtResponse: >{ + 'bar': [ + TxtResourceRecord('bar', future, text: 'authCode=xyz\n'), + ], + }, + ), + logger: logger, + flutterUsage: TestUsage(), + ), + }); + + testUsingContext('succeeds with iOS device with mDNS network device', () async { + final FakeIOSDevice device = FakeIOSDevice( + logReader: fakeLogReader, + portForwarder: portForwarder, + majorSdkVersion: 16, + interfaceType: IOSDeviceConnectionInterface.network, + ); + testDeviceManager.devices = [device]; + final FakeHotRunner hotRunner = FakeHotRunner(); + hotRunner.onAttach = ( + Completer? connectionInfoCompleter, + Completer? appStartedCompleter, + bool allowExistingDdsInstance, + bool enableDevTools, + ) async => 0; + hotRunner.exited = false; + hotRunner.isWaitingForObservatory = false; + final FakeHotRunnerFactory hotRunnerFactory = FakeHotRunnerFactory() + ..hotRunner = hotRunner; + + await createTestCommandRunner(AttachCommand( + hotRunnerFactory: hotRunnerFactory, + artifacts: artifacts, + stdio: stdio, + logger: logger, + terminal: terminal, + signals: signals, + platform: platform, + processInfo: processInfo, + fileSystem: testFileSystem, + )).run(['attach']); + await fakeLogReader.dispose(); + + expect(portForwarder.devicePort, null); + expect(portForwarder.hostPort, hostPort); + expect(hotRunnerFactory.devices, hasLength(1)); + + final FlutterDevice flutterDevice = hotRunnerFactory.devices.first; + final Uri? observatoryUri = await flutterDevice.observatoryUris?.first; + expect(observatoryUri.toString(), 'http://111.111.111.111:123/xyz/'); + }, overrides: { + FileSystem: () => testFileSystem, + ProcessManager: () => FakeProcessManager.any(), + Logger: () => logger, + DeviceManager: () => testDeviceManager, + MDnsVmServiceDiscovery: () => MDnsVmServiceDiscovery( + mdnsClient: FakeMDnsClient([], >{}), + preliminaryMDnsClient: FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + ], + >{ + 'srv-foo': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'target-foo'), + ], + }, + ipResponse: >{ + 'target-foo': [ + IPAddressResourceRecord('target-foo', 0, address: InternetAddress.tryParse('111.111.111.111')!), + ], + }, + txtResponse: >{ + 'srv-foo': [ + TxtResourceRecord('srv-foo', future, text: 'authCode=xyz\n'), + ], + }, + ), + logger: logger, + flutterUsage: TestUsage(), + ), + }); + + testUsingContext('succeeds with iOS device with mDNS network device with debug-port', () async { + final FakeIOSDevice device = FakeIOSDevice( + logReader: fakeLogReader, + portForwarder: portForwarder, + majorSdkVersion: 16, + interfaceType: IOSDeviceConnectionInterface.network, + ); + testDeviceManager.devices = [device]; + final FakeHotRunner hotRunner = FakeHotRunner(); + hotRunner.onAttach = ( + Completer? connectionInfoCompleter, + Completer? appStartedCompleter, + bool allowExistingDdsInstance, + bool enableDevTools, + ) async => 0; + hotRunner.exited = false; + hotRunner.isWaitingForObservatory = false; + final FakeHotRunnerFactory hotRunnerFactory = FakeHotRunnerFactory() + ..hotRunner = hotRunner; + + await createTestCommandRunner(AttachCommand( + hotRunnerFactory: hotRunnerFactory, + artifacts: artifacts, + stdio: stdio, + logger: logger, + terminal: terminal, + signals: signals, + platform: platform, + processInfo: processInfo, + fileSystem: testFileSystem, + )).run(['attach', '--debug-port', '123']); + await fakeLogReader.dispose(); + + expect(portForwarder.devicePort, null); + expect(portForwarder.hostPort, hostPort); + expect(hotRunnerFactory.devices, hasLength(1)); + + final FlutterDevice flutterDevice = hotRunnerFactory.devices.first; + final Uri? observatoryUri = await flutterDevice.observatoryUris?.first; + expect(observatoryUri.toString(), 'http://111.111.111.111:123/xyz/'); + }, overrides: { + FileSystem: () => testFileSystem, + ProcessManager: () => FakeProcessManager.any(), + Logger: () => logger, + DeviceManager: () => testDeviceManager, + MDnsVmServiceDiscovery: () => MDnsVmServiceDiscovery( + mdnsClient: FakeMDnsClient([], >{}), + preliminaryMDnsClient: FakeMDnsClient( + [ + PtrResourceRecord('bar', future, domainName: 'srv-bar'), + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + ], + >{ + 'srv-bar': [ + SrvResourceRecord('srv-bar', future, port: 321, weight: 1, priority: 1, target: 'target-bar'), + ], + 'srv-foo': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'target-foo'), + ], + }, + ipResponse: >{ + 'target-foo': [ + IPAddressResourceRecord('target-foo', 0, address: InternetAddress.tryParse('111.111.111.111')!), + ], + }, + txtResponse: >{ + 'srv-foo': [ + TxtResourceRecord('srv-foo', future, text: 'authCode=xyz\n'), + ], + }, + ), + logger: logger, + flutterUsage: TestUsage(), + ), + }); + + testUsingContext('succeeds with iOS device with mDNS network device with debug-url', () async { + final FakeIOSDevice device = FakeIOSDevice( + logReader: fakeLogReader, + portForwarder: portForwarder, + majorSdkVersion: 16, + interfaceType: IOSDeviceConnectionInterface.network, + ); + testDeviceManager.devices = [device]; + final FakeHotRunner hotRunner = FakeHotRunner(); + hotRunner.onAttach = ( + Completer? connectionInfoCompleter, + Completer? appStartedCompleter, + bool allowExistingDdsInstance, + bool enableDevTools, + ) async => 0; + hotRunner.exited = false; + hotRunner.isWaitingForObservatory = false; + final FakeHotRunnerFactory hotRunnerFactory = FakeHotRunnerFactory() + ..hotRunner = hotRunner; + + await createTestCommandRunner(AttachCommand( + hotRunnerFactory: hotRunnerFactory, + artifacts: artifacts, + stdio: stdio, + logger: logger, + terminal: terminal, + signals: signals, + platform: platform, + processInfo: processInfo, + fileSystem: testFileSystem, + )).run(['attach', '--debug-url', 'https://0.0.0.0:123']); + await fakeLogReader.dispose(); + + expect(portForwarder.devicePort, null); + expect(portForwarder.hostPort, hostPort); + expect(hotRunnerFactory.devices, hasLength(1)); + + final FlutterDevice flutterDevice = hotRunnerFactory.devices.first; + final Uri? observatoryUri = await flutterDevice.observatoryUris?.first; + expect(observatoryUri.toString(), 'http://111.111.111.111:123/xyz/'); + }, overrides: { + FileSystem: () => testFileSystem, + ProcessManager: () => FakeProcessManager.any(), + Logger: () => logger, + DeviceManager: () => testDeviceManager, + MDnsVmServiceDiscovery: () => MDnsVmServiceDiscovery( + mdnsClient: FakeMDnsClient([], >{}), + preliminaryMDnsClient: FakeMDnsClient( + [ + PtrResourceRecord('bar', future, domainName: 'srv-bar'), + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + ], + >{ + 'srv-bar': [ + SrvResourceRecord('srv-bar', future, port: 321, weight: 1, priority: 1, target: 'target-bar'), + ], + 'srv-foo': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'target-foo'), + ], + }, + ipResponse: >{ + 'target-foo': [ + IPAddressResourceRecord('target-foo', 0, address: InternetAddress.tryParse('111.111.111.111')!), + ], + }, + txtResponse: >{ + 'srv-foo': [ + TxtResourceRecord('srv-foo', future, text: 'authCode=xyz\n'), + ], + }, + ), logger: logger, flutterUsage: TestUsage(), ), @@ -979,9 +1288,16 @@ class FakeIOSDevice extends Fake implements IOSDevice { DevicePortForwarder? portForwarder, DeviceLogReader? logReader, this.onGetLogReader, + this.interfaceType = IOSDeviceConnectionInterface.none, + this.majorSdkVersion = 0, }) : _portForwarder = portForwarder, _logReader = logReader; final DevicePortForwarder? _portForwarder; + @override + int majorSdkVersion; + + @override + final IOSDeviceConnectionInterface interfaceType; @override DevicePortForwarder get portForwarder => _portForwarder!; @@ -1029,12 +1345,14 @@ class FakeIOSDevice extends Fake implements IOSDevice { class FakeMDnsClient extends Fake implements MDnsClient { FakeMDnsClient(this.ptrRecords, this.srvResponse, { this.txtResponse = const >{}, + this.ipResponse = const >{}, this.osErrorOnStart = false, }); final List ptrRecords; final Map> srvResponse; final Map> txtResponse; + final Map> ipResponse; final bool osErrorOnStart; @override @@ -1054,7 +1372,7 @@ class FakeMDnsClient extends Fake implements MDnsClient { ResourceRecordQuery query, { Duration timeout = const Duration(seconds: 5), }) { - if (T == PtrResourceRecord && query.fullyQualifiedName == MDnsObservatoryDiscovery.dartObservatoryName) { + if (T == PtrResourceRecord && query.fullyQualifiedName == MDnsVmServiceDiscovery.dartVmServiceName) { return Stream.fromIterable(ptrRecords) as Stream; } if (T == SrvResourceRecord) { @@ -1065,6 +1383,10 @@ class FakeMDnsClient extends Fake implements MDnsClient { final String key = query.fullyQualifiedName; return Stream.fromIterable(txtResponse[key] ?? []) as Stream; } + if (T == IPAddressResourceRecord) { + final String key = query.fullyQualifiedName; + return Stream.fromIterable(ipResponse[key] ?? []) as Stream; + } throw UnsupportedError('Unsupported query type $T'); } diff --git a/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart index 19f2eb2489ca..e4d9e63362bf 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart @@ -21,6 +21,8 @@ import 'package:flutter_tools/src/commands/drive.dart'; import 'package:flutter_tools/src/dart/pub.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/drive/drive_service.dart'; +import 'package:flutter_tools/src/ios/devices.dart'; +import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/project.dart'; import 'package:package_config/package_config.dart'; import 'package:test/fake.dart'; @@ -406,6 +408,94 @@ void main() { FileSystem: () => MemoryFileSystem.test(), ProcessManager: () => FakeProcessManager.any(), }); + + testUsingContext('Port publication not disabled for network device', () async { + final DriveCommand command = DriveCommand( + fileSystem: fileSystem, + logger: logger, + platform: platform, + signals: signals, + ); + + fileSystem.file('lib/main.dart').createSync(recursive: true); + fileSystem.file('test_driver/main_test.dart').createSync(recursive: true); + fileSystem.file('pubspec.yaml').createSync(); + + final Device networkDevice = FakeIosDevice() + ..interfaceType = IOSDeviceConnectionInterface.network; + fakeDeviceManager.devices = [networkDevice]; + + await expectLater(() => createTestCommandRunner(command).run([ + 'drive', + ]), throwsToolExit()); + + final DebuggingOptions options = await command.createDebuggingOptions(false); + expect(options.disablePortPublication, false); + }, overrides: { + Cache: () => Cache.test(processManager: FakeProcessManager.any()), + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + DeviceManager: () => fakeDeviceManager, + }); + + testUsingContext('Port publication is disabled for wired device', () async { + final DriveCommand command = DriveCommand( + fileSystem: fileSystem, + logger: logger, + platform: platform, + signals: signals, + ); + + fileSystem.file('lib/main.dart').createSync(recursive: true); + fileSystem.file('test_driver/main_test.dart').createSync(recursive: true); + fileSystem.file('pubspec.yaml').createSync(); + + await expectLater(() => createTestCommandRunner(command).run([ + 'drive', + ]), throwsToolExit()); + + final Device usbDevice = FakeIosDevice() + ..interfaceType = IOSDeviceConnectionInterface.usb; + fakeDeviceManager.devices = [usbDevice]; + + final DebuggingOptions options = await command.createDebuggingOptions(false); + expect(options.disablePortPublication, true); + }, overrides: { + Cache: () => Cache.test(processManager: FakeProcessManager.any()), + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + DeviceManager: () => fakeDeviceManager, + }); + + testUsingContext('Port publication does not default to enabled for network device if flag manually added', () async { + final DriveCommand command = DriveCommand( + fileSystem: fileSystem, + logger: logger, + platform: platform, + signals: signals, + ); + + fileSystem.file('lib/main.dart').createSync(recursive: true); + fileSystem.file('test_driver/main_test.dart').createSync(recursive: true); + fileSystem.file('pubspec.yaml').createSync(); + + final Device networkDevice = FakeIosDevice() + ..interfaceType = IOSDeviceConnectionInterface.network; + fakeDeviceManager.devices = [networkDevice]; + + await expectLater(() => createTestCommandRunner(command).run([ + 'drive', + '--no-publish-port' + ]), throwsToolExit()); + + final DebuggingOptions options = await command.createDebuggingOptions(false); + expect(options.disablePortPublication, true); + }, overrides: { + Cache: () => Cache.test(processManager: FakeProcessManager.any()), + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + DeviceManager: () => fakeDeviceManager, + }); } // Unfortunately Device, despite not being immutable, has an `operator ==`. @@ -577,3 +667,14 @@ class FakeProcessSignal extends Fake implements io.ProcessSignal { @override Stream watch() => controller.stream; } + +// Unfortunately Device, despite not being immutable, has an `operator ==`. +// Until we fix that, we have to also ignore related lints here. +// ignore: avoid_implementing_value_types +class FakeIosDevice extends Fake implements IOSDevice { + @override + IOSDeviceConnectionInterface interfaceType = IOSDeviceConnectionInterface.usb; + + @override + Future get targetPlatform async => TargetPlatform.ios; +} diff --git a/packages/flutter_tools/test/general.shard/device_test.dart b/packages/flutter_tools/test/general.shard/device_test.dart index e957f20a7b29..d97a6a24de0b 100644 --- a/packages/flutter_tools/test/general.shard/device_test.dart +++ b/packages/flutter_tools/test/general.shard/device_test.dart @@ -11,6 +11,7 @@ import 'package:flutter_tools/src/base/utils.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/convert.dart'; import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/project.dart'; import 'package:test/fake.dart'; @@ -554,6 +555,53 @@ void main() { ); }); + testWithoutContext('Get launch arguments for physical device with iPv4 network connection', () { + final DebuggingOptions original = DebuggingOptions.enabled( + BuildInfo.debug, + ); + + final List launchArguments = original.getIOSLaunchArguments( + EnvironmentType.physical, + null, + {}, + interfaceType: IOSDeviceConnectionInterface.network, + ); + + expect( + launchArguments.join(' '), + [ + '--enable-dart-profiling', + '--enable-checked-mode', + '--verify-entry-points', + '--observatory-host=0.0.0.0', + ].join(' '), + ); + }); + + testWithoutContext('Get launch arguments for physical device with iPv6 network connection', () { + final DebuggingOptions original = DebuggingOptions.enabled( + BuildInfo.debug, + ); + + final List launchArguments = original.getIOSLaunchArguments( + EnvironmentType.physical, + null, + {}, + ipv6: true, + interfaceType: IOSDeviceConnectionInterface.network, + ); + + expect( + launchArguments.join(' '), + [ + '--enable-dart-profiling', + '--enable-checked-mode', + '--verify-entry-points', + '--observatory-host=::0', + ].join(' '), + ); + }); + testWithoutContext('Get launch arguments for physical device with debugging disabled with available launch arguments', () { final DebuggingOptions original = DebuggingOptions.disabled( BuildInfo.debug, diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index c212a08b1fed..e26ef2b153fb 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -19,9 +19,11 @@ import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/mdns_discovery.dart'; import 'package:test/fake.dart'; import '../../src/common.dart'; +import '../../src/context.dart'; import '../../src/fake_devices.dart'; import '../../src/fake_process_manager.dart'; import '../../src/fakes.dart'; @@ -66,9 +68,10 @@ const FakeCommand kLaunchDebugCommand = FakeCommand(command: [ FakeCommand attachDebuggerCommand({ IOSink? stdin, Completer? completer, + bool isNetworkDevice = false, }) { return FakeCommand( - command: const [ + command: [ 'script', '-t', '0', @@ -79,9 +82,12 @@ FakeCommand attachDebuggerCommand({ '--bundle', '/', '--debug', - '--no-wifi', + if (!isNetworkDevice) '--no-wifi', '--args', - '--enable-dart-profiling --enable-checked-mode --verify-entry-points', + if (isNetworkDevice) + '--enable-dart-profiling --enable-checked-mode --verify-entry-points --observatory-host=0.0.0.0' + else + '--enable-dart-profiling --enable-checked-mode --verify-entry-points', ], completer: completer, environment: const { @@ -188,7 +194,7 @@ void main() { expect(await device.stopApp(iosApp), false); }); - testWithoutContext('IOSDevice.startApp prints warning message if discovery takes longer than configured timeout', () async { + testWithoutContext('IOSDevice.startApp prints warning message if discovery takes longer than configured timeout for wired device', () async { final FileSystem fileSystem = MemoryFileSystem.test(); final BufferLogger logger = BufferLogger.test(); final CompleterIOSink stdin = CompleterIOSink(); @@ -226,12 +232,59 @@ void main() { expect(launchResult.started, true); expect(launchResult.hasObservatory, true); expect(await device.stopApp(iosApp), false); - expect(logger.errorText, contains('iOS Observatory not discovered after 30 seconds. This is taking much longer than expected...')); + expect(logger.errorText, contains('The Dart VM Service was not discovered after 30 seconds. This is taking much longer than expected...')); expect(utf8.decoder.convert(stdin.writes.first), contains('process interrupt')); completer.complete(); expect(processManager, hasNoRemainingExpectations); }); + testUsingContext('IOSDevice.startApp prints warning message if discovery takes longer than configured timeout for wireless device', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final BufferLogger logger = BufferLogger.test(); + final CompleterIOSink stdin = CompleterIOSink(); + final Completer completer = Completer(); + final FakeProcessManager processManager = FakeProcessManager.list([ + attachDebuggerCommand(stdin: stdin, completer: completer, isNetworkDevice: true), + ]); + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + logger: logger, + interfaceType: IOSDeviceConnectionInterface.network, + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: fileSystem.currentDirectory, + applicationPackage: fileSystem.currentDirectory, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + + final LaunchResult launchResult = await device.startApp(iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + discoveryTimeout: Duration.zero, + ); + + expect(launchResult.started, true); + expect(launchResult.hasObservatory, true); + expect(await device.stopApp(iosApp), false); + expect(logger.errorText, contains('The Dart VM Service was not discovered after 45 seconds. This is taking much longer than expected...')); + expect(logger.errorText, contains('Click "Allow" to the prompt asking if you would like to find and connect devices on your local network.')); + completer.complete(); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + MDnsVmServiceDiscovery: () => FakeMDnsVmServiceDiscovery(), + }); + testWithoutContext('IOSDevice.startApp succeeds in release mode', () async { final FileSystem fileSystem = MemoryFileSystem.test(); final FakeProcessManager processManager = FakeProcessManager.list([ @@ -505,6 +558,7 @@ IOSDevice setUpIOSDevice({ Logger? logger, ProcessManager? processManager, IOSDeploy? iosDeploy, + IOSDeviceConnectionInterface interfaceType = IOSDeviceConnectionInterface.usb, }) { final Artifacts artifacts = Artifacts.test(); final FakePlatform macPlatform = FakePlatform( @@ -542,7 +596,7 @@ IOSDevice setUpIOSDevice({ cache: cache, ), cpuArchitecture: DarwinArch.arm64, - interfaceType: IOSDeviceConnectionInterface.usb, + interfaceType: interfaceType, ); } @@ -554,3 +608,18 @@ class FakeDevicePortForwarder extends Fake implements DevicePortForwarder { disposed = true; } } + +class FakeMDnsVmServiceDiscovery extends Fake implements MDnsVmServiceDiscovery { + @override + Future getVMServiceUriForLaunch( + String applicationId, + Device device, { + bool usesIpv6 = false, + int? hostVmservicePort, + required int deviceVmservicePort, + bool isNetworkDevice = false, + Duration timeout = Duration.zero, + }) async { + return Uri.tryParse('http://0.0.0.0:1234'); + } +} diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart index 3013f5f3e640..ee97168d4bd9 100644 --- a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart @@ -479,7 +479,7 @@ void main() { stdout: devicesOutput, )); final List devices = await xcdevice.getAvailableIOSDevices(); - expect(devices, hasLength(3)); + expect(devices, hasLength(4)); expect(devices[0].id, '00008027-00192736010F802E'); expect(devices[0].name, 'An iPhone (Space Gray)'); expect(await devices[0].sdkNameAndVersion, 'iOS 13.3 17C54'); @@ -488,10 +488,14 @@ void main() { expect(devices[1].name, 'iPad 1'); expect(await devices[1].sdkNameAndVersion, 'iOS 10.1 14C54'); expect(devices[1].cpuArchitecture, DarwinArch.armv7); - expect(devices[2].id, 'f577a7903cc54959be2e34bc4f7f80b7009efcf4'); - expect(devices[2].name, 'iPad 2'); + expect(devices[2].id, '234234234234234234345445687594e089dede3c44'); + expect(devices[2].name, 'A networked iPad'); expect(await devices[2].sdkNameAndVersion, 'iOS 10.1 14C54'); expect(devices[2].cpuArchitecture, DarwinArch.arm64); // Defaults to arm64 for unknown architecture. + expect(devices[3].id, 'f577a7903cc54959be2e34bc4f7f80b7009efcf4'); + expect(devices[3].name, 'iPad 2'); + expect(await devices[3].sdkNameAndVersion, 'iOS 10.1 14C54'); + expect(devices[3].cpuArchitecture, DarwinArch.arm64); // Defaults to arm64 for unknown architecture. expect(fakeProcessManager, hasNoRemainingExpectations); }, overrides: { Platform: () => macPlatform, diff --git a/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart b/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart index 42fae0e758c6..8bba83fa5d34 100644 --- a/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart +++ b/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart @@ -17,7 +17,7 @@ import '../src/common.dart'; void main() { group('mDNS Discovery', () { - final int year3000 = DateTime(3000).millisecondsSinceEpoch; + final int future = DateTime.now().add(const Duration(days: 1)).millisecondsSinceEpoch; setUp(() { setNetworkInterfaceLister( @@ -33,209 +33,652 @@ void main() { resetNetworkInterfaceLister(); }); + group('for attach', () { + late MDnsClient emptyClient; - testWithoutContext('No ports available', () async { - final MDnsClient client = FakeMDnsClient([], >{}); + setUp(() { + emptyClient = FakeMDnsClient([], >{}); + }); - final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery( - mdnsClient: client, - logger: BufferLogger.test(), - flutterUsage: TestUsage(), - ); - final int? port = (await portDiscovery.query())?.port; - expect(port, isNull); - }); - - testWithoutContext('Prints helpful message when there is no ipv4 link local address.', () async { - final MDnsClient client = FakeMDnsClient([], >{}); - final BufferLogger logger = BufferLogger.test(); - final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery( - mdnsClient: client, - logger: logger, - flutterUsage: TestUsage(), - ); - final Uri? uri = await portDiscovery.getObservatoryUri( - '', - FakeIOSDevice(), - ); - expect(uri, isNull); - expect(logger.errorText, contains('Personal Hotspot')); - }); - - testWithoutContext('One port available, no appId', () async { - final MDnsClient client = FakeMDnsClient( - [ - PtrResourceRecord('foo', year3000, domainName: 'bar'), - ], - >{ - 'bar': [ - SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'), + testWithoutContext('Find result in preliminary client', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), ], - }, - ); - - final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery( - mdnsClient: client, - logger: BufferLogger.test(), - flutterUsage: TestUsage(), - ); - final int? port = (await portDiscovery.query())?.port; - expect(port, 123); - }); - - testWithoutContext('One port available, no appId, with authCode', () async { - final MDnsClient client = FakeMDnsClient( - [ - PtrResourceRecord('foo', year3000, domainName: 'bar'), - ], - >{ - 'bar': [ - SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'), + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 123, weight: 1, priority: 1, target: 'appId'), + ], + }, + ); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: emptyClient, + preliminaryMDnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + final MDnsVmServiceDiscoveryResult? result = await portDiscovery.queryForAttach(); + expect(result, isNotNull); + }); + + testWithoutContext('Do not find result in preliminary client, but find in main client', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), ], - }, - txtResponse: >{ - 'bar': [ - TxtResourceRecord('bar', year3000, text: 'authCode=xyz\n'), + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 123, weight: 1, priority: 1, target: 'appId'), + ], + }, + ); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + final MDnsVmServiceDiscoveryResult? result = await portDiscovery.queryForAttach(); + expect(result, isNotNull); + }); + + testWithoutContext('Find multiple in preliminary client', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), + PtrResourceRecord('baz', future, domainName: 'fiz'), ], - }, - ); - - final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery( - mdnsClient: client, - logger: BufferLogger.test(), - flutterUsage: TestUsage(), - ); - final MDnsObservatoryDiscoveryResult? result = await portDiscovery.query(); - expect(result?.port, 123); - expect(result?.authCode, 'xyz/'); - }); - - testWithoutContext('Multiple ports available, without appId', () async { - final MDnsClient client = FakeMDnsClient( - [ - PtrResourceRecord('foo', year3000, domainName: 'bar'), - PtrResourceRecord('baz', year3000, domainName: 'fiz'), - ], - >{ - 'bar': [ - SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'), + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 123, weight: 1, priority: 1, target: 'appId'), + ], + 'fiz': [ + SrvResourceRecord('fiz', future, port: 321, weight: 1, priority: 1, target: 'local'), + ], + }, + ); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: emptyClient, + preliminaryMDnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + expect(portDiscovery.queryForAttach, throwsToolExit()); + }); + + testWithoutContext('No ports available', () async { + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: emptyClient, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + final int? port = (await portDiscovery.queryForAttach())?.port; + expect(port, isNull); + }); + + testWithoutContext('Prints helpful message when there is no ipv4 link local address.', () async { + final BufferLogger logger = BufferLogger.test(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: emptyClient, + preliminaryMDnsClient: emptyClient, + logger: logger, + flutterUsage: TestUsage(), + ); + final Uri? uri = await portDiscovery.getVMServiceUriForAttach( + '', + FakeIOSDevice(), + ); + expect(uri, isNull); + expect(logger.errorText, contains('Personal Hotspot')); + }); + + testWithoutContext('One port available, no appId', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), ], - 'fiz': [ - SrvResourceRecord('fiz', year3000, port: 321, weight: 1, priority: 1, target: 'local'), + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 123, weight: 1, priority: 1, target: 'appId'), + ], + }, + ); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + final int? port = (await portDiscovery.queryForAttach())?.port; + expect(port, 123); + }); + + testWithoutContext('One port available, no appId, with authCode', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), ], - }, - ); - - final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery( - mdnsClient: client, - logger: BufferLogger.test(), - flutterUsage: TestUsage(), - ); - expect(portDiscovery.query, throwsToolExit()); + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 123, weight: 1, priority: 1, target: 'appId'), + ], + }, + txtResponse: >{ + 'bar': [ + TxtResourceRecord('bar', future, text: 'authCode=xyz\n'), + ], + }, + ); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + final MDnsVmServiceDiscoveryResult? result = await portDiscovery.queryForAttach(); + expect(result?.port, 123); + expect(result?.authCode, 'xyz/'); + }); + + testWithoutContext('Multiple ports available, with appId', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), + PtrResourceRecord('baz', future, domainName: 'fiz'), + ], + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 123, weight: 1, priority: 1, target: 'appId'), + ], + 'fiz': [ + SrvResourceRecord('fiz', future, port: 321, weight: 1, priority: 1, target: 'local'), + ], + }, + ); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + final int? port = (await portDiscovery.queryForAttach(applicationId: 'fiz'))?.port; + expect(port, 321); + }); + + testWithoutContext('Multiple ports available per process, with appId', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), + PtrResourceRecord('baz', future, domainName: 'fiz'), + ], + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 1234, weight: 1, priority: 1, target: 'appId'), + SrvResourceRecord('bar', future, port: 123, weight: 1, priority: 1, target: 'appId'), + ], + 'fiz': [ + SrvResourceRecord('fiz', future, port: 4321, weight: 1, priority: 1, target: 'local'), + SrvResourceRecord('fiz', future, port: 321, weight: 1, priority: 1, target: 'local'), + ], + }, + ); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + final int? port = (await portDiscovery.queryForAttach(applicationId: 'bar'))?.port; + expect(port, 1234); + }); + + testWithoutContext('Throws Exception when client throws OSError on start', () async { + final MDnsClient client = FakeMDnsClient( + [], >{}, + osErrorOnStart: true, + ); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + expect( + () async => portDiscovery.queryForAttach(), + throwsException, + ); + }); + + testWithoutContext('Correctly builds VM Service URI with hostVmservicePort == 0', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), + ], + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 123, weight: 1, priority: 1, target: 'appId'), + ], + }, + ); + + final FakeIOSDevice device = FakeIOSDevice(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + final Uri? uri = await portDiscovery.getVMServiceUriForAttach('bar', device, hostVmservicePort: 0); + expect(uri.toString(), 'http://127.0.0.1:123/'); + }); + + testWithoutContext('Get network device IP (iPv4)', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), + ], + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 1234, weight: 1, priority: 1, target: 'appId'), + ], + }, + ipResponse: >{ + 'appId': [ + IPAddressResourceRecord('Device IP', 0, address: InternetAddress.tryParse('111.111.111.111')!), + ], + }, + txtResponse: >{ + 'bar': [ + TxtResourceRecord('bar', future, text: 'authCode=xyz\n'), + ], + }, + ); + + final FakeIOSDevice device = FakeIOSDevice(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + final Uri? uri = await portDiscovery.getVMServiceUriForAttach( + 'bar', + device, + isNetworkDevice: true, + ); + expect(uri.toString(), 'http://111.111.111.111:1234/xyz/'); + }); + + testWithoutContext('Get network device IP (iPv6)', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), + ], + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 1234, weight: 1, priority: 1, target: 'appId'), + ], + }, + ipResponse: >{ + 'appId': [ + IPAddressResourceRecord('Device IP', 0, address: InternetAddress.tryParse('1111:1111:1111:1111:1111:1111:1111:1111')!), + ], + }, + txtResponse: >{ + 'bar': [ + TxtResourceRecord('bar', future, text: 'authCode=xyz\n'), + ], + }, + ); + + final FakeIOSDevice device = FakeIOSDevice(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + final Uri? uri = await portDiscovery.getVMServiceUriForAttach( + 'bar', + device, + isNetworkDevice: true, + ); + expect(uri.toString(), 'http://[1111:1111:1111:1111:1111:1111:1111:1111]:1234/xyz/'); + }); + + testWithoutContext('Throw error if unable to find VM service with app id and device port', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + PtrResourceRecord('bar', future, domainName: 'srv-bar'), + PtrResourceRecord('baz', future, domainName: 'srv-boo'), + ], + >{ + 'srv-foo': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'target-foo'), + ], + 'srv-bar': [ + SrvResourceRecord('srv-bar', future, port: 123, weight: 1, priority: 1, target: 'target-bar'), + ], + 'srv-baz': [ + SrvResourceRecord('srv-baz', future, port: 123, weight: 1, priority: 1, target: 'target-baz'), + ], + }, + ); + final FakeIOSDevice device = FakeIOSDevice(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + expect( + portDiscovery.getVMServiceUriForAttach( + 'srv-bar', + device, + deviceVmservicePort: 321, + ), + throwsToolExit( + message: 'Did not find a Dart VM Service advertised for srv-bar on port 321.' + ), + ); + }); + + testWithoutContext('Throw error if unable to find VM Service with app id', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + ], + >{ + 'srv-foo': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'target-foo'), + ], + }, + ); + final FakeIOSDevice device = FakeIOSDevice(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + preliminaryMDnsClient: emptyClient, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + expect( + portDiscovery.getVMServiceUriForAttach( + 'srv-asdf', + device, + ), + throwsToolExit( + message: 'Did not find a Dart VM Service advertised for srv-asdf.' + ), + ); + }); }); - testWithoutContext('Multiple ports available, with appId', () async { - final MDnsClient client = FakeMDnsClient( - [ - PtrResourceRecord('foo', year3000, domainName: 'bar'), - PtrResourceRecord('baz', year3000, domainName: 'fiz'), - ], - >{ - 'bar': [ - SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'), + group('for launch', () { + testWithoutContext('No ports available', () async { + final MDnsClient client = FakeMDnsClient([], >{}); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + final MDnsVmServiceDiscoveryResult? result = await portDiscovery.queryForLaunch( + applicationId: 'app-id', + deviceVmservicePort: 123, + ); + + expect(result, null); + }); + + testWithoutContext('Prints helpful message when there is no ipv4 link local address.', () async { + final MDnsClient client = FakeMDnsClient([], >{}); + final BufferLogger logger = BufferLogger.test(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: logger, + flutterUsage: TestUsage(), + ); + + final Uri? uri = await portDiscovery.getVMServiceUriForLaunch( + '', + FakeIOSDevice(), + deviceVmservicePort: 0, + ); + expect(uri, isNull); + expect(logger.errorText, contains('Personal Hotspot')); + }); + + testWithoutContext('Throws Exception when client throws OSError on start', () async { + final MDnsClient client = FakeMDnsClient( + [], >{}, + osErrorOnStart: true, + ); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + expect( + () async => portDiscovery.queryForLaunch(applicationId: 'app-id', deviceVmservicePort: 123), + throwsException, + ); + }); + + testWithoutContext('Correctly builds VM Service URI with hostVmservicePort == 0', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), ], - 'fiz': [ - SrvResourceRecord('fiz', year3000, port: 321, weight: 1, priority: 1, target: 'local'), + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 123, weight: 1, priority: 1, target: 'appId'), + ], + }, + ); + + final FakeIOSDevice device = FakeIOSDevice(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + final Uri? uri = await portDiscovery.getVMServiceUriForLaunch( + 'bar', + device, + hostVmservicePort: 0, + deviceVmservicePort: 123, + ); + expect(uri.toString(), 'http://127.0.0.1:123/'); + }); + + testWithoutContext('Get network device IP (iPv4)', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), ], - }, - ); - - final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery( - mdnsClient: client, - logger: BufferLogger.test(), - flutterUsage: TestUsage(), - ); - final int? port = (await portDiscovery.query(applicationId: 'fiz'))?.port; - expect(port, 321); + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 1234, weight: 1, priority: 1, target: 'appId'), + ], + }, + ipResponse: >{ + 'appId': [ + IPAddressResourceRecord('Device IP', 0, address: InternetAddress.tryParse('111.111.111.111')!), + ], + }, + txtResponse: >{ + 'bar': [ + TxtResourceRecord('bar', future, text: 'authCode=xyz\n'), + ], + }, + ); + + final FakeIOSDevice device = FakeIOSDevice(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + final Uri? uri = await portDiscovery.getVMServiceUriForLaunch( + 'bar', + device, + isNetworkDevice: true, + deviceVmservicePort: 1234, + ); + expect(uri.toString(), 'http://111.111.111.111:1234/xyz/'); + }); + + testWithoutContext('Get network device IP (iPv6)', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'bar'), + ], + >{ + 'bar': [ + SrvResourceRecord('bar', future, port: 1234, weight: 1, priority: 1, target: 'appId'), + ], + }, + ipResponse: >{ + 'appId': [ + IPAddressResourceRecord('Device IP', 0, address: InternetAddress.tryParse('1111:1111:1111:1111:1111:1111:1111:1111')!), + ], + }, + txtResponse: >{ + 'bar': [ + TxtResourceRecord('bar', future, text: 'authCode=xyz\n'), + ], + }, + ); + + final FakeIOSDevice device = FakeIOSDevice(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + final Uri? uri = await portDiscovery.getVMServiceUriForLaunch( + 'bar', + device, + isNetworkDevice: true, + deviceVmservicePort: 1234, + ); + expect(uri.toString(), 'http://[1111:1111:1111:1111:1111:1111:1111:1111]:1234/xyz/'); + }); + + testWithoutContext('Throw error if unable to find VM Service with app id and device port', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + PtrResourceRecord('bar', future, domainName: 'srv-bar'), + PtrResourceRecord('baz', future, domainName: 'srv-boo'), + ], + >{ + 'srv-foo': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'target-foo'), + ], + 'srv-bar': [ + SrvResourceRecord('srv-bar', future, port: 123, weight: 1, priority: 1, target: 'target-bar'), + ], + 'srv-baz': [ + SrvResourceRecord('srv-baz', future, port: 123, weight: 1, priority: 1, target: 'target-baz'), + ], + }, + ); + final FakeIOSDevice device = FakeIOSDevice(); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + expect( + portDiscovery.getVMServiceUriForLaunch( + 'srv-bar', + device, + deviceVmservicePort: 321, + ), + throwsToolExit( + message:'Did not find a Dart VM Service advertised for srv-bar on port 321.'), + ); + }); }); - testWithoutContext('Multiple ports available per process, with appId', () async { + testWithoutContext('Find firstMatchingVmService with many available and no application id', () async { final MDnsClient client = FakeMDnsClient( [ - PtrResourceRecord('foo', year3000, domainName: 'bar'), - PtrResourceRecord('baz', year3000, domainName: 'fiz'), + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + PtrResourceRecord('bar', future, domainName: 'srv-bar'), + PtrResourceRecord('baz', future, domainName: 'srv-boo'), ], >{ - 'bar': [ - SrvResourceRecord('bar', year3000, port: 1234, weight: 1, priority: 1, target: 'appId'), - SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'), + 'srv-foo': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'target-foo'), + ], + 'srv-bar': [ + SrvResourceRecord('srv-bar', future, port: 123, weight: 1, priority: 1, target: 'target-bar'), ], - 'fiz': [ - SrvResourceRecord('fiz', year3000, port: 4321, weight: 1, priority: 1, target: 'local'), - SrvResourceRecord('fiz', year3000, port: 321, weight: 1, priority: 1, target: 'local'), + 'srv-baz': [ + SrvResourceRecord('srv-baz', future, port: 123, weight: 1, priority: 1, target: 'target-baz'), ], }, ); - final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery( + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( mdnsClient: client, logger: BufferLogger.test(), flutterUsage: TestUsage(), ); - final int? port = (await portDiscovery.query(applicationId: 'bar'))?.port; - expect(port, 1234); + final MDnsVmServiceDiscoveryResult? result = await portDiscovery.firstMatchingVmService(client); + expect(result?.domainName, 'srv-foo'); }); - testWithoutContext('Query returns null', () async { - final MDnsClient client = FakeMDnsClient( - [], - >{}, - ); - - final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery( - mdnsClient: client, - logger: BufferLogger.test(), - flutterUsage: TestUsage(), - ); - final int? port = (await portDiscovery.query(applicationId: 'bar'))?.port; - expect(port, isNull); - }); - - testWithoutContext('Throws Exception when client throws OSError on start', () async { - final MDnsClient client = FakeMDnsClient([], >{}, osErrorOnStart: true); - - - final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery( - mdnsClient: client, - logger: BufferLogger.test(), - flutterUsage: TestUsage(), - ); - expect( - () async => portDiscovery.query(), - throwsException, - ); - }); - - testWithoutContext('Correctly builds Observatory URI with hostVmservicePort == 0', () async { + testWithoutContext('Find firstMatchingVmService app id', () async { final MDnsClient client = FakeMDnsClient( [ - PtrResourceRecord('foo', year3000, domainName: 'bar'), + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + PtrResourceRecord('bar', future, domainName: 'srv-bar'), + PtrResourceRecord('baz', future, domainName: 'srv-boo'), ], >{ - 'bar': [ - SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'), + 'srv-foo': [ + SrvResourceRecord('srv-foo', future, port: 111, weight: 1, priority: 1, target: 'target-foo'), + ], + 'srv-bar': [ + SrvResourceRecord('srv-bar', future, port: 222, weight: 1, priority: 1, target: 'target-bar'), + SrvResourceRecord('srv-bar', future, port: 333, weight: 1, priority: 1, target: 'target-bar-2'), + ], + 'srv-baz': [ + SrvResourceRecord('srv-baz', future, port: 444, weight: 1, priority: 1, target: 'target-baz'), ], }, ); - final FakeIOSDevice device = FakeIOSDevice(); - final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery( + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( mdnsClient: client, logger: BufferLogger.test(), flutterUsage: TestUsage(), ); - final Uri? uri = await portDiscovery.getObservatoryUri('bar', device, hostVmservicePort: 0); - expect(uri.toString(), 'http://127.0.0.1:123/'); + final MDnsVmServiceDiscoveryResult? result = await portDiscovery.firstMatchingVmService( + client, + applicationId: 'srv-bar' + ); + expect(result?.domainName, 'srv-bar'); + expect(result?.port, 222); }); }); } @@ -243,12 +686,14 @@ void main() { class FakeMDnsClient extends Fake implements MDnsClient { FakeMDnsClient(this.ptrRecords, this.srvResponse, { this.txtResponse = const >{}, + this.ipResponse = const >{}, this.osErrorOnStart = false, }); final List ptrRecords; final Map> srvResponse; final Map> txtResponse; + final Map> ipResponse; final bool osErrorOnStart; @override @@ -268,7 +713,7 @@ class FakeMDnsClient extends Fake implements MDnsClient { ResourceRecordQuery query, { Duration timeout = const Duration(seconds: 5), }) { - if (T == PtrResourceRecord && query.fullyQualifiedName == MDnsObservatoryDiscovery.dartObservatoryName) { + if (T == PtrResourceRecord && query.fullyQualifiedName == MDnsVmServiceDiscovery.dartVmServiceName) { return Stream.fromIterable(ptrRecords) as Stream; } if (T == SrvResourceRecord) { @@ -279,6 +724,10 @@ class FakeMDnsClient extends Fake implements MDnsClient { final String key = query.fullyQualifiedName; return Stream.fromIterable(txtResponse[key] ?? []) as Stream; } + if (T == IPAddressResourceRecord) { + final String key = query.fullyQualifiedName; + return Stream.fromIterable(ipResponse[key] ?? []) as Stream; + } throw UnsupportedError('Unsupported query type $T'); }