// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; import '../artifacts.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; import '../build_info.dart'; import '../cache.dart'; import '../convert.dart'; import '../device.dart'; import '../globals.dart' as globals; import '../ios/devices.dart'; import '../ios/ios_deploy.dart'; import '../ios/iproxy.dart'; import '../ios/mac.dart'; import '../reporting/reporting.dart'; import 'xcode.dart'; class XCDeviceEventNotification { XCDeviceEventNotification( this.eventType, this.eventInterface, this.deviceIdentifier, ); final XCDeviceEvent eventType; final XCDeviceEventInterface eventInterface; final String deviceIdentifier; } enum XCDeviceEvent { attach, detach, } enum XCDeviceEventInterface { usb(name: 'usb', connectionInterface: DeviceConnectionInterface.attached), wifi(name: 'wifi', connectionInterface: DeviceConnectionInterface.wireless); const XCDeviceEventInterface({ required this.name, required this.connectionInterface, }); final String name; final DeviceConnectionInterface connectionInterface; } /// A utility class for interacting with Xcode xcdevice command line tools. class XCDevice { XCDevice({ required Artifacts artifacts, required Cache cache, required ProcessManager processManager, required Logger logger, required Xcode xcode, required Platform platform, required IProxy iproxy, }) : _processUtils = ProcessUtils(logger: logger, processManager: processManager), _logger = logger, _iMobileDevice = IMobileDevice( artifacts: artifacts, cache: cache, logger: logger, processManager: processManager, ), _iosDeploy = IOSDeploy( artifacts: artifacts, cache: cache, logger: logger, platform: platform, processManager: processManager, ), _iProxy = iproxy, _xcode = xcode { _setupDeviceIdentifierByEventStream(); } void dispose() { _usbDeviceObserveProcess?.kill(); _wifiDeviceObserveProcess?.kill(); _usbDeviceWaitProcess?.kill(); _wifiDeviceWaitProcess?.kill(); } final ProcessUtils _processUtils; final Logger _logger; final IMobileDevice _iMobileDevice; final IOSDeploy _iosDeploy; final Xcode _xcode; final IProxy _iProxy; List<Object>? _cachedListResults; Process? _usbDeviceObserveProcess; Process? _wifiDeviceObserveProcess; StreamController<XCDeviceEventNotification>? _observeStreamController; @visibleForTesting StreamController<XCDeviceEventNotification>? waitStreamController; Process? _usbDeviceWaitProcess; Process? _wifiDeviceWaitProcess; void _setupDeviceIdentifierByEventStream() { // _observeStreamController Should always be available for listeners // in case polling needs to be stopped and restarted. _observeStreamController = StreamController<XCDeviceEventNotification>.broadcast( onListen: _startObservingTetheredIOSDevices, onCancel: _stopObservingTetheredIOSDevices, ); } bool get isInstalled => _xcode.isInstalledAndMeetsVersionCheck; Future<List<Object>?> _getAllDevices({ bool useCache = false, required Duration timeout, }) async { if (!isInstalled) { _logger.printTrace("Xcode not found. Run 'flutter doctor' for more information."); return null; } if (useCache && _cachedListResults != null) { return _cachedListResults; } try { // USB-tethered devices should be found quickly. 1 second timeout is faster than the default. final RunResult result = await _processUtils.run( <String>[ ..._xcode.xcrunCommand(), 'xcdevice', 'list', '--timeout', timeout.inSeconds.toString(), ], throwOnError: true, ); if (result.exitCode == 0) { final String listOutput = result.stdout; try { final List<Object> listResults = (json.decode(result.stdout) as List<Object?>).whereType<Object>().toList(); _cachedListResults = listResults; return listResults; } on FormatException { // xcdevice logs errors and crashes to stdout. _logger.printError('xcdevice returned non-JSON response: $listOutput'); return null; } } _logger.printTrace('xcdevice returned an error:\n${result.stderr}'); } on ProcessException catch (exception) { _logger.printTrace('Process exception running xcdevice list:\n$exception'); } on ArgumentError catch (exception) { _logger.printTrace('Argument exception running xcdevice list:\n$exception'); } return null; } /// Observe identifiers (UDIDs) of devices as they attach and detach. /// /// Each attach and detach event contains information on the event type, /// the event interface, and the device identifer. Stream<XCDeviceEventNotification>? observedDeviceEvents() { if (!isInstalled) { _logger.printTrace("Xcode not found. Run 'flutter doctor' for more information."); return null; } return _observeStreamController?.stream; } // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 // Attach: 00008027-00192736010F802E // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 final RegExp _observationIdentifierPattern = RegExp(r'^(\w*): ([\w-]*)$'); Future<void> _startObservingTetheredIOSDevices() async { try { if (_usbDeviceObserveProcess != null || _wifiDeviceObserveProcess != null) { throw Exception('xcdevice observe restart failed'); } _usbDeviceObserveProcess = await _startObserveProcess( XCDeviceEventInterface.usb, ); _wifiDeviceObserveProcess = await _startObserveProcess( XCDeviceEventInterface.wifi, ); final Future<void> usbProcessExited = _usbDeviceObserveProcess!.exitCode.then((int status) { _logger.printTrace('xcdevice observe --usb exited with code $exitCode'); // Kill other process in case only one was killed. _wifiDeviceObserveProcess?.kill(); }); final Future<void> wifiProcessExited = _wifiDeviceObserveProcess!.exitCode.then((int status) { _logger.printTrace('xcdevice observe --wifi exited with code $exitCode'); // Kill other process in case only one was killed. _usbDeviceObserveProcess?.kill(); }); unawaited(Future.wait(<Future<void>>[ usbProcessExited, wifiProcessExited, ]).whenComplete(() async { if (_observeStreamController?.hasListener ?? false) { // Tell listeners the process died. await _observeStreamController?.close(); } _usbDeviceObserveProcess = null; _wifiDeviceObserveProcess = null; // Reopen it so new listeners can resume polling. _setupDeviceIdentifierByEventStream(); })); } on ProcessException catch (exception, stackTrace) { _observeStreamController?.addError(exception, stackTrace); } on ArgumentError catch (exception, stackTrace) { _observeStreamController?.addError(exception, stackTrace); } } Future<Process> _startObserveProcess(XCDeviceEventInterface eventInterface) { // Run in interactive mode (via script) to convince // xcdevice it has a terminal attached in order to redirect stdout. return _streamXCDeviceEventCommand( <String>[ 'script', '-t', '0', '/dev/null', ..._xcode.xcrunCommand(), 'xcdevice', 'observe', '--${eventInterface.name}', ], prefix: 'xcdevice observe --${eventInterface.name}: ', mapFunction: (String line) { final XCDeviceEventNotification? event = _processXCDeviceStdOut( line, eventInterface, ); if (event != null) { _observeStreamController?.add(event); } return line; }, ); } /// Starts the command and streams stdout/stderr from the child process to /// this process' stdout/stderr. /// /// If [mapFunction] is present, all lines are forwarded to [mapFunction] for /// further processing. Future<Process> _streamXCDeviceEventCommand( List<String> cmd, { String prefix = '', StringConverter? mapFunction, }) async { final Process process = await _processUtils.start(cmd); final StreamSubscription<String> stdoutSubscription = process.stdout .transform<String>(utf8.decoder) .transform<String>(const LineSplitter()) .listen((String line) { String? mappedLine = line; if (mapFunction != null) { mappedLine = mapFunction(line); } if (mappedLine != null) { final String message = '$prefix$mappedLine'; _logger.printTrace(message); } }); final StreamSubscription<String> stderrSubscription = process.stderr .transform<String>(utf8.decoder) .transform<String>(const LineSplitter()) .listen((String line) { String? mappedLine = line; if (mapFunction != null) { mappedLine = mapFunction(line); } if (mappedLine != null) { _logger.printError('$prefix$mappedLine', wrap: false); } }); unawaited(process.exitCode.whenComplete(() { stdoutSubscription.cancel(); stderrSubscription.cancel(); })); return process; } void _stopObservingTetheredIOSDevices() { _usbDeviceObserveProcess?.kill(); _wifiDeviceObserveProcess?.kill(); } XCDeviceEventNotification? _processXCDeviceStdOut( String line, XCDeviceEventInterface eventInterface, ) { // xcdevice observe example output of UDIDs: // // Listening for all devices, on both interfaces. // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 // Attach: 00008027-00192736010F802E // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 final RegExpMatch? match = _observationIdentifierPattern.firstMatch(line); if (match != null && match.groupCount == 2) { final String verb = match.group(1)!.toLowerCase(); final String identifier = match.group(2)!; if (verb.startsWith('attach')) { return XCDeviceEventNotification( XCDeviceEvent.attach, eventInterface, identifier, ); } else if (verb.startsWith('detach')) { return XCDeviceEventNotification( XCDeviceEvent.detach, eventInterface, identifier, ); } } return null; } /// Wait for a connect event for a specific device. Must use device's exact UDID. /// /// To cancel this process, call [cancelWaitForDeviceToConnect]. Future<XCDeviceEventNotification?> waitForDeviceToConnect( String deviceId, ) async { try { if (_usbDeviceWaitProcess != null || _wifiDeviceWaitProcess != null) { throw Exception('xcdevice wait restart failed'); } waitStreamController = StreamController<XCDeviceEventNotification>(); _usbDeviceWaitProcess = await _startWaitProcess( deviceId, XCDeviceEventInterface.usb, ); _wifiDeviceWaitProcess = await _startWaitProcess( deviceId, XCDeviceEventInterface.wifi, ); final Future<void> usbProcessExited = _usbDeviceWaitProcess!.exitCode.then((int status) { _logger.printTrace('xcdevice wait --usb exited with code $exitCode'); // Kill other process in case only one was killed. _wifiDeviceWaitProcess?.kill(); }); final Future<void> wifiProcessExited = _wifiDeviceWaitProcess!.exitCode.then((int status) { _logger.printTrace('xcdevice wait --wifi exited with code $exitCode'); // Kill other process in case only one was killed. _usbDeviceWaitProcess?.kill(); }); final Future<void> allProcessesExited = Future.wait( <Future<void>>[ usbProcessExited, wifiProcessExited, ]).whenComplete(() async { _usbDeviceWaitProcess = null; _wifiDeviceWaitProcess = null; await waitStreamController?.close(); }); return await Future.any( <Future<XCDeviceEventNotification?>>[ allProcessesExited.then((_) => null), waitStreamController!.stream.first.whenComplete(() async { cancelWaitForDeviceToConnect(); }), ], ); } on ProcessException catch (exception, stackTrace) { _logger.printTrace('Process exception running xcdevice wait:\n$exception\n$stackTrace'); } on ArgumentError catch (exception, stackTrace) { _logger.printTrace('Process exception running xcdevice wait:\n$exception\n$stackTrace'); } on StateError { _logger.printTrace('Stream broke before first was found'); return null; } return null; } Future<Process> _startWaitProcess(String deviceId, XCDeviceEventInterface eventInterface) { // Run in interactive mode (via script) to convince // xcdevice it has a terminal attached in order to redirect stdout. return _streamXCDeviceEventCommand( <String>[ 'script', '-t', '0', '/dev/null', ..._xcode.xcrunCommand(), 'xcdevice', 'wait', '--${eventInterface.name}', deviceId, ], prefix: 'xcdevice wait --${eventInterface.name}: ', mapFunction: (String line) { final XCDeviceEventNotification? event = _processXCDeviceStdOut( line, eventInterface, ); if (event != null && event.eventType == XCDeviceEvent.attach) { waitStreamController?.add(event); } return line; }, ); } void cancelWaitForDeviceToConnect() { _usbDeviceWaitProcess?.kill(); _wifiDeviceWaitProcess?.kill(); } /// A list of [IOSDevice]s. This list includes connected devices and /// disconnected wireless devices. /// /// Sometimes devices may have incorrect connection information /// (`isConnected`, `connectionInterface`) if it timed out before it could get the /// information. Wireless devices can take longer to get the correct /// information. /// /// [timeout] defaults to 2 seconds. Future<List<IOSDevice>> getAvailableIOSDevices({ Duration? timeout }) async { final List<Object>? allAvailableDevices = await _getAllDevices(timeout: timeout ?? const Duration(seconds: 2)); if (allAvailableDevices == null) { return const <IOSDevice>[]; } // [ // { // "simulator" : true, // "operatingSystemVersion" : "13.3 (17K446)", // "available" : true, // "platform" : "com.apple.platform.appletvsimulator", // "modelCode" : "AppleTV5,3", // "identifier" : "CBB5E1ED-2172-446E-B4E7-F2B5823DBBA6", // "architecture" : "x86_64", // "modelName" : "Apple TV", // "name" : "Apple TV" // }, // { // "simulator" : false, // "operatingSystemVersion" : "13.3 (17C54)", // "interface" : "usb", // "available" : true, // "platform" : "com.apple.platform.iphoneos", // "modelCode" : "iPhone8,1", // "identifier" : "d83d5bc53967baa0ee18626ba87b6254b2ab5418", // "architecture" : "arm64", // "modelName" : "iPhone 6s", // "name" : "iPhone" // }, // { // "simulator" : true, // "operatingSystemVersion" : "6.1.1 (17S445)", // "available" : true, // "platform" : "com.apple.platform.watchsimulator", // "modelCode" : "Watch5,4", // "identifier" : "2D74FB11-88A0-44D0-B81E-C0C142B1C94A", // "architecture" : "i386", // "modelName" : "Apple Watch Series 5 - 44mm", // "name" : "Apple Watch Series 5 - 44mm" // }, // ... final List<IOSDevice> devices = <IOSDevice>[]; for (final Object device in allAvailableDevices) { if (device is Map<String, Object?>) { // Only include iPhone, iPad, iPod, or other iOS devices. if (!_isIPhoneOSDevice(device)) { continue; } final String? identifier = device['identifier'] as String?; final String? name = device['name'] as String?; if (identifier == null || name == null) { continue; } bool devModeEnabled = true; bool isConnected = true; final Map<String, Object?>? errorProperties = _errorProperties(device); if (errorProperties != null) { final String? errorMessage = _parseErrorMessage(errorProperties); if (errorMessage != null) { if (errorMessage.contains('not paired')) { UsageEvent('device', 'ios-trust-failure', flutterUsage: globals.flutterUsage).send(); } _logger.printTrace(errorMessage); } final int? code = _errorCode(errorProperties); // Temporary error -10: iPhone is busy: Preparing debugger support for iPhone. // Sometimes the app launch will fail on these devices until Xcode is done setting up the device. // Other times this is a false positive and the app will successfully launch despite the error. if (code != -10) { isConnected = false; } if (code == 6) { devModeEnabled = false; } } String? sdkVersion = _sdkVersion(device); if (sdkVersion != null) { final String? buildVersion = _buildVersion(device); if (buildVersion != null) { sdkVersion = '$sdkVersion $buildVersion'; } } devices.add(IOSDevice( identifier, name: name, cpuArchitecture: _cpuArchitecture(device), connectionInterface: _interfaceType(device), isConnected: isConnected, sdkVersion: sdkVersion, iProxy: _iProxy, fileSystem: globals.fs, logger: _logger, iosDeploy: _iosDeploy, iMobileDevice: _iMobileDevice, platform: globals.platform, devModeEnabled: devModeEnabled )); } } return devices; } /// Despite the name, com.apple.platform.iphoneos includes iPhone, iPads, and all iOS devices. /// Excludes simulators. static bool _isIPhoneOSDevice(Map<String, Object?> deviceProperties) { final Object? platform = deviceProperties['platform']; if (platform is String) { return platform == 'com.apple.platform.iphoneos'; } return false; } static Map<String, Object?>? _errorProperties(Map<String, Object?> deviceProperties) { final Object? error = deviceProperties['error']; return error is Map<String, Object?> ? error : null; } static int? _errorCode(Map<String, Object?>? errorProperties) { if (errorProperties == null) { return null; } final Object? code = errorProperties['code']; return code is int ? code : null; } static DeviceConnectionInterface _interfaceType(Map<String, Object?> deviceProperties) { // Interface can be "usb" or "network". It can also be missing // (e.g. simulators do not have an interface property). // If the interface is "network", use `DeviceConnectionInterface.wireless`, // otherwise use `DeviceConnectionInterface.attached. final Object? interface = deviceProperties['interface']; if (interface is String && interface.toLowerCase() == 'network') { return DeviceConnectionInterface.wireless; } return DeviceConnectionInterface.attached; } static String? _sdkVersion(Map<String, Object?> deviceProperties) { final Object? operatingSystemVersion = deviceProperties['operatingSystemVersion']; if (operatingSystemVersion is String) { // Parse out the OS version, ignore the build number in parentheses. // "13.3 (17C54)" final RegExp operatingSystemRegex = RegExp(r'(.*) \(.*\)$'); if (operatingSystemRegex.hasMatch(operatingSystemVersion.trim())) { return operatingSystemRegex.firstMatch(operatingSystemVersion.trim())?.group(1); } return operatingSystemVersion; } return null; } static String? _buildVersion(Map<String, Object?> deviceProperties) { final Object? operatingSystemVersion = deviceProperties['operatingSystemVersion']; if (operatingSystemVersion is String) { // Parse out the build version, for example 17C54 from "13.3 (17C54)". final RegExp buildVersionRegex = RegExp(r'\(.*\)$'); return buildVersionRegex.firstMatch(operatingSystemVersion)?.group(0)?.replaceAll(RegExp('[()]'), ''); } return null; } DarwinArch _cpuArchitecture(Map<String, Object?> deviceProperties) { DarwinArch? cpuArchitecture; final Object? architecture = deviceProperties['architecture']; if (architecture is String) { try { cpuArchitecture = getIOSArchForName(architecture); } on Exception { // Fallback to default iOS architecture. Future-proof against a // theoretical version of Xcode that changes this string to something // slightly different like "ARM64", or armv7 variations like // armv7s and armv7f. if (architecture.startsWith('armv7')) { cpuArchitecture = DarwinArch.armv7; } else { cpuArchitecture = DarwinArch.arm64; } _logger.printWarning( 'Unknown architecture $architecture, defaulting to ' '${cpuArchitecture.name}', ); } } return cpuArchitecture ?? DarwinArch.arm64; } /// Error message parsed from xcdevice. null if no error. static String? _parseErrorMessage(Map<String, Object?>? errorProperties) { // { // "simulator" : false, // "operatingSystemVersion" : "13.3 (17C54)", // "interface" : "usb", // "available" : false, // "platform" : "com.apple.platform.iphoneos", // "modelCode" : "iPhone8,1", // "identifier" : "98206e7a4afd4aedaff06e687594e089dede3c44", // "architecture" : "arm64", // "modelName" : "iPhone 6s", // "name" : "iPhone", // "error" : { // "code" : -9, // "failureReason" : "", // "underlyingErrors" : [ // { // "code" : 5, // "failureReason" : "allowsSecureServices: 1. isConnected: 0. Platform: <DVTPlatform:0x7f804ce32880:'com.apple.platform.iphoneos':<DVTFilePath:0x7f804ce32800:'\/Users\/magder\/Applications\/Xcode_11-3-1.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform'>>. DTDKDeviceIdentifierIsIDID: 0", // "description" : "📱<DVTiOSDevice (0x7f801f190450), iPhone, iPhone, 13.3 (17C54), d83d5bc53967baa0ee18626ba87b6254b2ab5418> -- Failed _shouldMakeReadyForDevelopment check even though device is not locked by passcode.", // "recoverySuggestion" : "", // "domain" : "com.apple.platform.iphoneos" // } // ], // "description" : "iPhone is not paired with your computer.", // "recoverySuggestion" : "To use iPhone with Xcode, unlock it and choose to trust this computer when prompted.", // "domain" : "com.apple.platform.iphoneos" // } // }, // { // "simulator" : false, // "operatingSystemVersion" : "13.3 (17C54)", // "interface" : "usb", // "available" : false, // "platform" : "com.apple.platform.iphoneos", // "modelCode" : "iPhone8,1", // "identifier" : "d83d5bc53967baa0ee18626ba87b6254b2ab5418", // "architecture" : "arm64", // "modelName" : "iPhone 6s", // "name" : "iPhone", // "error" : { // "code" : -9, // "failureReason" : "", // "description" : "iPhone is not paired with your computer.", // "domain" : "com.apple.platform.iphoneos" // } // } // ... if (errorProperties == null) { return null; } final StringBuffer errorMessage = StringBuffer('Error: '); final Object? description = errorProperties['description']; if (description is String) { errorMessage.write(description); if (!description.endsWith('.')) { errorMessage.write('.'); } } else { errorMessage.write('Xcode pairing error.'); } final Object? recoverySuggestion = errorProperties['recoverySuggestion']; if (recoverySuggestion is String) { errorMessage.write(' $recoverySuggestion'); } final int? code = _errorCode(errorProperties); if (code != null) { errorMessage.write(' (code $code)'); } return errorMessage.toString(); } /// List of all devices reporting errors. Future<List<String>> getDiagnostics() async { final List<Object>? allAvailableDevices = await _getAllDevices( useCache: true, timeout: const Duration(seconds: 2) ); if (allAvailableDevices == null) { return const <String>[]; } final List<String> diagnostics = <String>[]; for (final Object deviceProperties in allAvailableDevices) { if (deviceProperties is! Map<String, Object?>) { continue; } final Map<String, Object?>? errorProperties = _errorProperties(deviceProperties); final String? errorMessage = _parseErrorMessage(errorProperties); if (errorMessage != null) { final int? code = _errorCode(errorProperties); // Error -13: iPhone is not connected. Xcode will continue when iPhone is connected. // This error is confusing since the device is not connected and maybe has not been connected // for a long time. Avoid showing it. if (code == -13 && errorMessage.contains('not connected')) { continue; } diagnostics.add(errorMessage); } } return diagnostics; } }