| // 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 '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'; |
| import '../base/os.dart'; |
| import '../base/platform.dart'; |
| import '../base/process.dart'; |
| import '../base/utils.dart'; |
| import '../base/version.dart'; |
| import '../build_info.dart'; |
| import '../convert.dart'; |
| 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'; |
| import 'application_package.dart'; |
| import 'core_devices.dart'; |
| import 'ios_deploy.dart'; |
| import 'ios_workflow.dart'; |
| import 'iproxy.dart'; |
| import 'mac.dart'; |
| import 'xcode_build_settings.dart'; |
| import 'xcode_debug.dart'; |
| import 'xcodeproj.dart'; |
| |
| class IOSDevices extends PollingDeviceDiscovery { |
| IOSDevices({ |
| required Platform platform, |
| required this.xcdevice, |
| required IOSWorkflow iosWorkflow, |
| required Logger logger, |
| }) : _platform = platform, |
| _iosWorkflow = iosWorkflow, |
| _logger = logger, |
| super('iOS devices'); |
| |
| final Platform _platform; |
| final IOSWorkflow _iosWorkflow; |
| final Logger _logger; |
| |
| @visibleForTesting |
| final XCDevice xcdevice; |
| |
| @override |
| bool get supportsPlatform => _platform.isMacOS; |
| |
| @override |
| bool get canListAnything => _iosWorkflow.canListDevices; |
| |
| @override |
| bool get requiresExtendedWirelessDeviceDiscovery => true; |
| |
| StreamSubscription<XCDeviceEventNotification>? _observedDeviceEventsSubscription; |
| |
| /// Cache for all devices found by `xcdevice list`, including not connected |
| /// devices. Used to minimize the need to call `xcdevice list`. |
| /// |
| /// Separate from `deviceNotifier` since `deviceNotifier` should only contain |
| /// connected devices. |
| final Map<String, IOSDevice> _cachedPolledDevices = <String, IOSDevice>{}; |
| |
| /// Maps device id to a map of the device's observed connections. When the |
| /// mapped connection is `true`, that means that observed events indicated |
| /// the device is connected via that particular interface. |
| /// |
| /// The device id must be missing from the map or both interfaces must be |
| /// false for the device to be considered disconnected. |
| /// |
| /// Example: |
| /// { |
| /// device-id: { |
| /// usb: false, |
| /// wifi: false, |
| /// }, |
| /// } |
| final Map<String, Map<XCDeviceEventInterface, bool>> _observedConnectionsByDeviceId = |
| <String, Map<XCDeviceEventInterface, bool>>{}; |
| |
| @override |
| Future<void> startPolling() async { |
| if (!_platform.isMacOS) { |
| throw UnsupportedError( |
| 'Control of iOS devices or simulators only supported on macOS.' |
| ); |
| } |
| if (!xcdevice.isInstalled) { |
| return; |
| } |
| |
| deviceNotifier ??= ItemListNotifier<Device>(); |
| |
| // Start by populating all currently attached devices. |
| _updateCachedDevices(await pollingGetDevices()); |
| _updateNotifierFromCache(); |
| |
| // cancel any outstanding subscriptions. |
| await _observedDeviceEventsSubscription?.cancel(); |
| _observedDeviceEventsSubscription = xcdevice.observedDeviceEvents()?.listen( |
| onDeviceEvent, |
| onError: (Object error, StackTrace stack) { |
| _logger.printTrace('Process exception running xcdevice observe:\n$error\n$stack'); |
| }, onDone: () { |
| // If xcdevice is killed or otherwise dies, polling will be stopped. |
| // No retry is attempted and the polling client will have to restart polling |
| // (restart the IDE). Avoid hammering on a process that is |
| // continuously failing. |
| _logger.printTrace('xcdevice observe stopped'); |
| }, |
| cancelOnError: true, |
| ); |
| } |
| |
| @visibleForTesting |
| Future<void> onDeviceEvent(XCDeviceEventNotification event) async { |
| final ItemListNotifier<Device>? notifier = deviceNotifier; |
| if (notifier == null) { |
| return; |
| } |
| Device? knownDevice; |
| for (final Device device in notifier.items) { |
| if (device.id == event.deviceIdentifier) { |
| knownDevice = device; |
| } |
| } |
| |
| final Map<XCDeviceEventInterface, bool> deviceObservedConnections = |
| _observedConnectionsByDeviceId[event.deviceIdentifier] ?? |
| <XCDeviceEventInterface, bool>{ |
| XCDeviceEventInterface.usb: false, |
| XCDeviceEventInterface.wifi: false, |
| }; |
| |
| if (event.eventType == XCDeviceEvent.attach) { |
| // Update device's observed connections. |
| deviceObservedConnections[event.eventInterface] = true; |
| _observedConnectionsByDeviceId[event.deviceIdentifier] = deviceObservedConnections; |
| |
| // If device was not already in notifier, add it. |
| if (knownDevice == null) { |
| if (_cachedPolledDevices[event.deviceIdentifier] == null) { |
| // If device is not found in cache, there's no way to get details |
| // for an individual attached device, so repopulate them all. |
| _updateCachedDevices(await pollingGetDevices()); |
| } |
| _updateNotifierFromCache(); |
| } |
| } else { |
| // Update device's observed connections. |
| deviceObservedConnections[event.eventInterface] = false; |
| _observedConnectionsByDeviceId[event.deviceIdentifier] = deviceObservedConnections; |
| |
| // If device is in the notifier and does not have other observed |
| // connections, remove it. |
| if (knownDevice != null && |
| !_deviceHasObservedConnection(deviceObservedConnections)) { |
| notifier.removeItem(knownDevice); |
| } |
| } |
| } |
| |
| /// Adds or updates devices in cache. Does not remove devices from cache. |
| void _updateCachedDevices(List<Device> devices) { |
| for (final Device device in devices) { |
| if (device is! IOSDevice) { |
| continue; |
| } |
| _cachedPolledDevices[device.id] = device; |
| } |
| } |
| |
| /// Updates notifier with devices found in the cache that are determined |
| /// to be connected. |
| void _updateNotifierFromCache() { |
| final ItemListNotifier<Device>? notifier = deviceNotifier; |
| if (notifier == null) { |
| return; |
| } |
| // Device is connected if it has either an observed usb or wifi connection |
| // or it has not been observed but was found as connected in the cache. |
| final List<Device> connectedDevices = _cachedPolledDevices.values.where((Device device) { |
| final Map<XCDeviceEventInterface, bool>? deviceObservedConnections = |
| _observedConnectionsByDeviceId[device.id]; |
| return (deviceObservedConnections != null && |
| _deviceHasObservedConnection(deviceObservedConnections)) || |
| (deviceObservedConnections == null && device.isConnected); |
| }).toList(); |
| |
| notifier.updateWithNewList(connectedDevices); |
| } |
| |
| bool _deviceHasObservedConnection( |
| Map<XCDeviceEventInterface, bool> deviceObservedConnections, |
| ) { |
| return (deviceObservedConnections[XCDeviceEventInterface.usb] ?? false) || |
| (deviceObservedConnections[XCDeviceEventInterface.wifi] ?? false); |
| } |
| |
| @override |
| Future<void> stopPolling() async { |
| await _observedDeviceEventsSubscription?.cancel(); |
| } |
| |
| @override |
| Future<List<Device>> pollingGetDevices({ Duration? timeout }) async { |
| if (!_platform.isMacOS) { |
| throw UnsupportedError( |
| 'Control of iOS devices or simulators only supported on macOS.' |
| ); |
| } |
| |
| return xcdevice.getAvailableIOSDevices(timeout: timeout); |
| } |
| |
| Future<Device?> waitForDeviceToConnect( |
| IOSDevice device, |
| Logger logger, |
| ) async { |
| final XCDeviceEventNotification? eventDetails = |
| await xcdevice.waitForDeviceToConnect(device.id); |
| |
| if (eventDetails != null) { |
| device.isConnected = true; |
| device.connectionInterface = eventDetails.eventInterface.connectionInterface; |
| return device; |
| } |
| return null; |
| } |
| |
| void cancelWaitForDeviceToConnect() { |
| xcdevice.cancelWaitForDeviceToConnect(); |
| } |
| |
| @override |
| Future<List<String>> getDiagnostics() async { |
| if (!_platform.isMacOS) { |
| return const <String>[ |
| 'Control of iOS devices or simulators only supported on macOS.', |
| ]; |
| } |
| |
| return xcdevice.getDiagnostics(); |
| } |
| |
| @override |
| List<String> get wellKnownIds => const <String>[]; |
| } |
| |
| class IOSDevice extends Device { |
| IOSDevice(super.id, { |
| required FileSystem fileSystem, |
| required this.name, |
| required this.cpuArchitecture, |
| required this.connectionInterface, |
| required this.isConnected, |
| required this.devModeEnabled, |
| required this.isCoreDevice, |
| String? sdkVersion, |
| required Platform platform, |
| required IOSDeploy iosDeploy, |
| required IMobileDevice iMobileDevice, |
| required IOSCoreDeviceControl coreDeviceControl, |
| required XcodeDebug xcodeDebug, |
| required IProxy iProxy, |
| required Logger logger, |
| }) |
| : _sdkVersion = sdkVersion, |
| _iosDeploy = iosDeploy, |
| _iMobileDevice = iMobileDevice, |
| _coreDeviceControl = coreDeviceControl, |
| _xcodeDebug = xcodeDebug, |
| _iproxy = iProxy, |
| _fileSystem = fileSystem, |
| _logger = logger, |
| _platform = platform, |
| super( |
| category: Category.mobile, |
| platformType: PlatformType.ios, |
| ephemeral: true, |
| ) { |
| if (!_platform.isMacOS) { |
| assert(false, 'Control of iOS devices or simulators only supported on Mac OS.'); |
| return; |
| } |
| } |
| |
| final String? _sdkVersion; |
| final IOSDeploy _iosDeploy; |
| final FileSystem _fileSystem; |
| final Logger _logger; |
| final Platform _platform; |
| final IMobileDevice _iMobileDevice; |
| final IOSCoreDeviceControl _coreDeviceControl; |
| final XcodeDebug _xcodeDebug; |
| final IProxy _iproxy; |
| |
| Version? get sdkVersion { |
| return Version.parse(_sdkVersion); |
| } |
| |
| /// May be 0 if version cannot be parsed. |
| int get majorSdkVersion { |
| return sdkVersion?.major ?? 0; |
| } |
| |
| @override |
| final String name; |
| |
| @override |
| bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease; |
| |
| final DarwinArch cpuArchitecture; |
| |
| @override |
| /// The [connectionInterface] provided from `XCDevice.getAvailableIOSDevices` |
| /// may not be accurate. Sometimes if it doesn't have a long enough time |
| /// to connect, wireless devices will have an interface of `usb`/`attached`. |
| /// This may change after waiting for the device to connect in |
| /// `waitForDeviceToConnect`. |
| DeviceConnectionInterface connectionInterface; |
| |
| @override |
| bool isConnected; |
| |
| /// CoreDevice is a device connectivity stack introduced in Xcode 15. Devices |
| /// with iOS 17 or greater are CoreDevices. |
| final bool isCoreDevice; |
| |
| final Map<IOSApp?, DeviceLogReader> _logReaders = <IOSApp?, DeviceLogReader>{}; |
| |
| DevicePortForwarder? _portForwarder; |
| |
| bool devModeEnabled = false; |
| |
| @visibleForTesting |
| IOSDeployDebugger? iosDeployDebugger; |
| |
| @override |
| Future<bool> get isLocalEmulator async => false; |
| |
| @override |
| Future<String?> get emulatorId async => null; |
| |
| @override |
| bool get supportsStartPaused => false; |
| |
| @override |
| Future<bool> isAppInstalled( |
| ApplicationPackage app, { |
| String? userIdentifier, |
| }) async { |
| bool result; |
| try { |
| if (isCoreDevice) { |
| result = await _coreDeviceControl.isAppInstalled( |
| bundleId: app.id, |
| deviceId: id, |
| ); |
| } else { |
| result = await _iosDeploy.isAppInstalled( |
| bundleId: app.id, |
| deviceId: id, |
| ); |
| } |
| } on ProcessException catch (e) { |
| _logger.printError(e.message); |
| return false; |
| } |
| return result; |
| } |
| |
| @override |
| Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false; |
| |
| @override |
| Future<bool> installApp( |
| covariant IOSApp app, { |
| String? userIdentifier, |
| }) async { |
| final Directory bundle = _fileSystem.directory(app.deviceBundlePath); |
| if (!bundle.existsSync()) { |
| _logger.printError('Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?'); |
| return false; |
| } |
| |
| int installationResult; |
| try { |
| if (isCoreDevice) { |
| installationResult = await _coreDeviceControl.installApp( |
| deviceId: id, |
| bundlePath: bundle.path, |
| ) ? 0 : 1; |
| } else { |
| installationResult = await _iosDeploy.installApp( |
| deviceId: id, |
| bundlePath: bundle.path, |
| appDeltaDirectory: app.appDeltaDirectory, |
| launchArguments: <String>[], |
| interfaceType: connectionInterface, |
| ); |
| } |
| } on ProcessException catch (e) { |
| _logger.printError(e.message); |
| return false; |
| } |
| if (installationResult != 0) { |
| _logger.printError('Could not install ${bundle.path} on $id.'); |
| _logger.printError('Try launching Xcode and selecting "Product > Run" to fix the problem:'); |
| _logger.printError(' open ios/Runner.xcworkspace'); |
| _logger.printError(''); |
| return false; |
| } |
| return true; |
| } |
| |
| @override |
| Future<bool> uninstallApp( |
| ApplicationPackage app, { |
| String? userIdentifier, |
| }) async { |
| int uninstallationResult; |
| try { |
| if (isCoreDevice) { |
| uninstallationResult = await _coreDeviceControl.uninstallApp( |
| deviceId: id, |
| bundleId: app.id, |
| ) ? 0 : 1; |
| } else { |
| uninstallationResult = await _iosDeploy.uninstallApp( |
| deviceId: id, |
| bundleId: app.id, |
| ); |
| } |
| } on ProcessException catch (e) { |
| _logger.printError(e.message); |
| return false; |
| } |
| if (uninstallationResult != 0) { |
| _logger.printError('Could not uninstall ${app.id} on $id.'); |
| return false; |
| } |
| return true; |
| } |
| |
| @override |
| // 32-bit devices are not supported. |
| bool isSupported() => cpuArchitecture == DarwinArch.arm64; |
| |
| @override |
| Future<LaunchResult> startApp( |
| IOSApp package, { |
| String? mainPath, |
| String? route, |
| required DebuggingOptions debuggingOptions, |
| Map<String, Object?> platformArgs = const <String, Object?>{}, |
| bool prebuiltApplication = false, |
| bool ipv6 = false, |
| String? userIdentifier, |
| @visibleForTesting Duration? discoveryTimeout, |
| @visibleForTesting ShutdownHooks? shutdownHooks, |
| }) async { |
| String? packageId; |
| if (isWirelesslyConnected && |
| debuggingOptions.debuggingEnabled && |
| debuggingOptions.disablePortPublication) { |
| throwToolExit('Cannot start app on wirelessly tethered iOS device. Try running again with the --publish-port flag'); |
| } |
| |
| // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) |
| // XcodeDebug workflow is used for CoreDevices (iOS 17+ and Xcode 15+). |
| // Force the use of XcodeDebug workflow in CI to test from older versions |
| // since devicelab has not yet been updated to iOS 17 and Xcode 15. |
| bool forceXcodeDebugWorkflow = false; |
| if (debuggingOptions.usingCISystem && |
| debuggingOptions.debuggingEnabled && |
| _platform.environment['FORCE_XCODE_DEBUG']?.toLowerCase() == 'true') { |
| forceXcodeDebugWorkflow = true; |
| } |
| |
| if (!prebuiltApplication) { |
| _logger.printTrace('Building ${package.name} for $id'); |
| |
| // Step 1: Build the precompiled/DBC application if necessary. |
| final XcodeBuildResult buildResult = await buildXcodeProject( |
| app: package as BuildableIOSApp, |
| buildInfo: debuggingOptions.buildInfo, |
| targetOverride: mainPath, |
| activeArch: cpuArchitecture, |
| deviceID: id, |
| ); |
| if (!buildResult.success) { |
| _logger.printError('Could not build the precompiled application for the device.'); |
| await diagnoseXcodeBuildFailure(buildResult, globals.flutterUsage, _logger); |
| _logger.printError(''); |
| return LaunchResult.failed(); |
| } |
| packageId = buildResult.xcodeBuildExecution?.buildSettings[IosProject.kProductBundleIdKey]; |
| } |
| |
| packageId ??= package.id; |
| |
| // Step 2: Check that the application exists at the specified path. |
| final Directory bundle = _fileSystem.directory(package.deviceBundlePath); |
| if (!bundle.existsSync()) { |
| _logger.printError('Could not find the built application bundle at ${bundle.path}.'); |
| return LaunchResult.failed(); |
| } |
| |
| // Step 3: Attempt to install the application on the device. |
| final List<String> launchArguments = debuggingOptions.getIOSLaunchArguments( |
| EnvironmentType.physical, |
| route, |
| platformArgs, |
| ipv6: ipv6, |
| interfaceType: connectionInterface, |
| isCoreDevice: isCoreDevice, |
| ); |
| Status startAppStatus = _logger.startProgress( |
| 'Installing and launching...', |
| ); |
| try { |
| ProtocolDiscovery? vmServiceDiscovery; |
| int installationResult = 1; |
| if (debuggingOptions.debuggingEnabled) { |
| _logger.printTrace('Debugging is enabled, connecting to vmService'); |
| final DeviceLogReader deviceLogReader = getLogReader( |
| app: package, |
| usingCISystem: debuggingOptions.usingCISystem, |
| ); |
| |
| // If the device supports syslog reading, prefer launching the app without |
| // attaching the debugger to avoid the overhead of the unnecessary extra running process. |
| if (majorSdkVersion >= IOSDeviceLogReader.minimumUniversalLoggingSdkVersion) { |
| iosDeployDebugger = _iosDeploy.prepareDebuggerForLaunch( |
| deviceId: id, |
| bundlePath: bundle.path, |
| appDeltaDirectory: package.appDeltaDirectory, |
| launchArguments: launchArguments, |
| interfaceType: connectionInterface, |
| uninstallFirst: debuggingOptions.uninstallFirst, |
| ); |
| if (deviceLogReader is IOSDeviceLogReader) { |
| deviceLogReader.debuggerStream = iosDeployDebugger; |
| } |
| } |
| // Don't port foward if debugging with a wireless device. |
| vmServiceDiscovery = ProtocolDiscovery.vmService( |
| deviceLogReader, |
| portForwarder: isWirelesslyConnected ? null : portForwarder, |
| hostPort: debuggingOptions.hostVmServicePort, |
| devicePort: debuggingOptions.deviceVmServicePort, |
| ipv6: ipv6, |
| logger: _logger, |
| ); |
| } |
| |
| if (isCoreDevice || forceXcodeDebugWorkflow) { |
| installationResult = await _startAppOnCoreDevice( |
| debuggingOptions: debuggingOptions, |
| package: package, |
| launchArguments: launchArguments, |
| mainPath: mainPath, |
| discoveryTimeout: discoveryTimeout, |
| shutdownHooks: shutdownHooks ?? globals.shutdownHooks, |
| ) ? 0 : 1; |
| } else if (iosDeployDebugger == null) { |
| installationResult = await _iosDeploy.launchApp( |
| deviceId: id, |
| bundlePath: bundle.path, |
| appDeltaDirectory: package.appDeltaDirectory, |
| launchArguments: launchArguments, |
| interfaceType: connectionInterface, |
| uninstallFirst: debuggingOptions.uninstallFirst, |
| ); |
| } else { |
| installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1; |
| } |
| if (installationResult != 0) { |
| _logger.printError('Could not run ${bundle.path} on $id.'); |
| _logger.printError('Try launching Xcode and selecting "Product > Run" to fix the problem:'); |
| _logger.printError(' open ios/Runner.xcworkspace'); |
| _logger.printError(''); |
| await dispose(); |
| return LaunchResult.failed(); |
| } |
| |
| if (!debuggingOptions.debuggingEnabled) { |
| return LaunchResult.succeeded(); |
| } |
| |
| _logger.printTrace('Application launched on the device. Waiting for Dart VM Service url.'); |
| |
| final int defaultTimeout; |
| if ((isCoreDevice || forceXcodeDebugWorkflow) && debuggingOptions.debuggingEnabled) { |
| // Core devices with debugging enabled takes longer because this |
| // includes time to install and launch the app on the device. |
| defaultTimeout = isWirelesslyConnected ? 75 : 60; |
| } else if (isWirelesslyConnected) { |
| defaultTimeout = 45; |
| } else { |
| defaultTimeout = 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 (isCoreDevice && debuggingOptions.debuggingEnabled) { |
| _logger.printError( |
| 'Open the Xcode window the project is opened in to ensure the app ' |
| 'is running. If the app is not running, try selecting "Product > Run" ' |
| 'to fix the problem.', |
| ); |
| } |
| // If debugging with a wireless device and the timeout is reached, remind the |
| // user to allow local network permissions. |
| if (isWirelesslyConnected) { |
| _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?.checkForSymbolsFiles(_fileSystem); |
| iosDeployDebugger?.pauseDumpBacktraceResume(); |
| } |
| }); |
| |
| Uri? localUri; |
| if (isWirelesslyConnected) { |
| // When using a CoreDevice, device logs are unavailable and therefore |
| // cannot be used to get the Dart VM url. Instead, get the Dart VM |
| // Service by finding services matching the app bundle id and the |
| // device name. |
| // |
| // If not using a CoreDevice, wait for the Dart VM url to be discovered |
| // via logs and then get the Dart VM Service by finding services matching |
| // the app bundle id and the Dart VM port. |
| // |
| // Then in both cases, get the device IP from the Dart VM Service to |
| // construct the Dart VM url using the device IP as the host. |
| if (isCoreDevice) { |
| localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( |
| packageId, |
| this, |
| usesIpv6: ipv6, |
| useDeviceIPAsHost: true, |
| ); |
| } else { |
| // Wait for Dart VM Service to start up. |
| final Uri? serviceURL = await vmServiceDiscovery?.uri; |
| if (serviceURL == null) { |
| await iosDeployDebugger?.stopAndDumpBacktrace(); |
| await dispose(); |
| 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, |
| useDeviceIPAsHost: true, |
| ); |
| |
| mDNSLookupTimer.cancel(); |
| } |
| } else { |
| if ((isCoreDevice || forceXcodeDebugWorkflow) && vmServiceDiscovery != null) { |
| // When searching for the Dart VM url, search for it via ProtocolDiscovery |
| // (device logs) and mDNS simultaneously, since both can be flaky at times. |
| final Future<Uri?> vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( |
| packageId, |
| this, |
| usesIpv6: ipv6, |
| ); |
| final Future<Uri?> vmUrlFromLogs = vmServiceDiscovery.uri; |
| localUri = await Future.any( |
| <Future<Uri?>>[vmUrlFromMDns, vmUrlFromLogs] |
| ); |
| } else { |
| localUri = await vmServiceDiscovery?.uri; |
| } |
| } |
| timer.cancel(); |
| if (localUri == null) { |
| await iosDeployDebugger?.stopAndDumpBacktrace(); |
| await dispose(); |
| return LaunchResult.failed(); |
| } |
| return LaunchResult.succeeded(vmServiceUri: localUri); |
| } on ProcessException catch (e) { |
| await iosDeployDebugger?.stopAndDumpBacktrace(); |
| _logger.printError(e.message); |
| await dispose(); |
| return LaunchResult.failed(); |
| } finally { |
| startAppStatus.stop(); |
| } |
| } |
| |
| /// Starting with Xcode 15 and iOS 17, `ios-deploy` stopped working due to |
| /// the new CoreDevice connectivity stack. Previously, `ios-deploy` was used |
| /// to install the app, launch the app, and start `debugserver`. |
| /// Xcode 15 introduced a new command line tool called `devicectl` that |
| /// includes much of the functionality supplied by `ios-deploy`. However, |
| /// `devicectl` lacks the ability to start a `debugserver` and therefore `ptrace`, which are needed |
| /// for debug mode due to using a JIT Dart VM. |
| /// |
| /// Therefore, when starting an app on a CoreDevice, use `devicectl` when |
| /// debugging is not enabled. Otherwise, use Xcode automation. |
| Future<bool> _startAppOnCoreDevice({ |
| required DebuggingOptions debuggingOptions, |
| required IOSApp package, |
| required List<String> launchArguments, |
| required String? mainPath, |
| required ShutdownHooks shutdownHooks, |
| @visibleForTesting Duration? discoveryTimeout, |
| }) async { |
| if (!debuggingOptions.debuggingEnabled) { |
| // Release mode |
| |
| // Install app to device |
| final bool installSuccess = await _coreDeviceControl.installApp( |
| deviceId: id, |
| bundlePath: package.deviceBundlePath, |
| ); |
| if (!installSuccess) { |
| return installSuccess; |
| } |
| |
| // Launch app to device |
| final bool launchSuccess = await _coreDeviceControl.launchApp( |
| deviceId: id, |
| bundleId: package.id, |
| launchArguments: launchArguments, |
| ); |
| |
| return launchSuccess; |
| } else { |
| _logger.printStatus( |
| 'You may be prompted to give access to control Xcode. Flutter uses Xcode ' |
| 'to run your app. If access is not allowed, you can change this through ' |
| 'your Settings > Privacy & Security > Automation.', |
| ); |
| final int launchTimeout = isWirelesslyConnected ? 45 : 30; |
| final Timer timer = Timer(discoveryTimeout ?? Duration(seconds: launchTimeout), () { |
| _logger.printError( |
| 'Xcode is taking longer than expected to start debugging the app. ' |
| 'Ensure the project is opened in Xcode.', |
| ); |
| }); |
| |
| XcodeDebugProject debugProject; |
| final FlutterProject flutterProject = FlutterProject.current(); |
| |
| if (package is PrebuiltIOSApp) { |
| debugProject = await _xcodeDebug.createXcodeProjectWithCustomBundle( |
| package.deviceBundlePath, |
| templateRenderer: globals.templateRenderer, |
| verboseLogging: _logger.isVerbose, |
| ); |
| } else if (package is BuildableIOSApp) { |
| // Before installing/launching/debugging with Xcode, update the build |
| // settings to use a custom configuration build directory so Xcode |
| // knows where to find the app bundle to launch. |
| final Directory bundle = _fileSystem.directory( |
| package.deviceBundlePath, |
| ); |
| await updateGeneratedXcodeProperties( |
| project: flutterProject, |
| buildInfo: debuggingOptions.buildInfo, |
| targetOverride: mainPath, |
| configurationBuildDir: bundle.parent.absolute.path, |
| ); |
| |
| final IosProject project = package.project; |
| final XcodeProjectInfo? projectInfo = await project.projectInfo(); |
| if (projectInfo == null) { |
| globals.printError('Xcode project not found.'); |
| return false; |
| } |
| if (project.xcodeWorkspace == null) { |
| globals.printError('Unable to get Xcode workspace.'); |
| return false; |
| } |
| final String? scheme = projectInfo.schemeFor(debuggingOptions.buildInfo); |
| if (scheme == null) { |
| projectInfo.reportFlavorNotFoundAndExit(); |
| } |
| |
| debugProject = XcodeDebugProject( |
| scheme: scheme, |
| xcodeProject: project.xcodeProject, |
| xcodeWorkspace: project.xcodeWorkspace!, |
| verboseLogging: _logger.isVerbose, |
| ); |
| } else { |
| // This should not happen. Currently, only PrebuiltIOSApp and |
| // BuildableIOSApp extend from IOSApp. |
| _logger.printError('IOSApp type ${package.runtimeType} is not recognized.'); |
| return false; |
| } |
| |
| final bool debugSuccess = await _xcodeDebug.debugApp( |
| project: debugProject, |
| deviceId: id, |
| launchArguments:launchArguments, |
| ); |
| timer.cancel(); |
| |
| // Kill Xcode on shutdown when running from CI |
| if (debuggingOptions.usingCISystem) { |
| shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true)); |
| } |
| |
| if (package is BuildableIOSApp) { |
| // After automating Xcode, reset the Generated settings to not include |
| // the custom configuration build directory. This is to prevent |
| // confusion if the project is later ran via Xcode rather than the |
| // Flutter CLI. |
| await updateGeneratedXcodeProperties( |
| project: flutterProject, |
| buildInfo: debuggingOptions.buildInfo, |
| targetOverride: mainPath, |
| ); |
| } |
| |
| return debugSuccess; |
| } |
| } |
| |
| @override |
| Future<bool> stopApp( |
| ApplicationPackage? app, { |
| String? userIdentifier, |
| }) async { |
| // If the debugger is not attached, killing the ios-deploy process won't stop the app. |
| final IOSDeployDebugger? deployDebugger = iosDeployDebugger; |
| if (deployDebugger != null && deployDebugger.debuggerAttached) { |
| return deployDebugger.exit(); |
| } |
| if (_xcodeDebug.debugStarted) { |
| return _xcodeDebug.exit(); |
| } |
| return false; |
| } |
| |
| @override |
| Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios; |
| |
| @override |
| Future<String> get sdkNameAndVersion async => 'iOS ${_sdkVersion ?? 'unknown version'}'; |
| |
| @override |
| DeviceLogReader getLogReader({ |
| covariant IOSApp? app, |
| bool includePastLogs = false, |
| bool usingCISystem = false, |
| }) { |
| assert(!includePastLogs, 'Past log reading not supported on iOS devices.'); |
| return _logReaders.putIfAbsent(app, () => IOSDeviceLogReader.create( |
| device: this, |
| app: app, |
| iMobileDevice: _iMobileDevice, |
| usingCISystem: usingCISystem, |
| )); |
| } |
| |
| @visibleForTesting |
| void setLogReader(IOSApp app, DeviceLogReader logReader) { |
| _logReaders[app] = logReader; |
| } |
| |
| @override |
| DevicePortForwarder get portForwarder => _portForwarder ??= IOSDevicePortForwarder( |
| logger: _logger, |
| iproxy: _iproxy, |
| id: id, |
| operatingSystemUtils: globals.os, |
| ); |
| |
| @visibleForTesting |
| set portForwarder(DevicePortForwarder forwarder) { |
| _portForwarder = forwarder; |
| } |
| |
| @override |
| void clearLogs() { } |
| |
| @override |
| bool get supportsScreenshot { |
| if (isCoreDevice) { |
| // `idevicescreenshot` stopped working with iOS 17 / Xcode 15 |
| // (https://github.com/flutter/flutter/issues/128598). |
| return false; |
| } |
| return _iMobileDevice.isInstalled; |
| } |
| |
| @override |
| Future<void> takeScreenshot(File outputFile) async { |
| await _iMobileDevice.takeScreenshot(outputFile, id, connectionInterface); |
| } |
| |
| @override |
| bool isSupportedForProject(FlutterProject flutterProject) { |
| return flutterProject.ios.existsSync(); |
| } |
| |
| @override |
| Future<void> dispose() async { |
| for (final DeviceLogReader logReader in _logReaders.values) { |
| logReader.dispose(); |
| } |
| _logReaders.clear(); |
| await _portForwarder?.dispose(); |
| } |
| } |
| |
| /// Decodes a vis-encoded syslog string to a UTF-8 representation. |
| /// |
| /// Apple's syslog logs are encoded in 7-bit form. Input bytes are encoded as follows: |
| /// 1. 0x00 to 0x19: non-printing range. Some ignored, some encoded as <...>. |
| /// 2. 0x20 to 0x7f: as-is, with the exception of 0x5c (backslash). |
| /// 3. 0x5c (backslash): octal representation \134. |
| /// 4. 0x80 to 0x9f: \M^x (using control-character notation for range 0x00 to 0x40). |
| /// 5. 0xa0: octal representation \240. |
| /// 6. 0xa1 to 0xf7: \M-x (where x is the input byte stripped of its high-order bit). |
| /// 7. 0xf8 to 0xff: unused in 4-byte UTF-8. |
| /// |
| /// See: [vis(3) manpage](https://www.freebsd.org/cgi/man.cgi?query=vis&sektion=3) |
| String decodeSyslog(String line) { |
| // UTF-8 values for \, M, -, ^. |
| const int kBackslash = 0x5c; |
| const int kM = 0x4d; |
| const int kDash = 0x2d; |
| const int kCaret = 0x5e; |
| |
| // Mask for the UTF-8 digit range. |
| const int kNum = 0x30; |
| |
| // Returns true when `byte` is within the UTF-8 7-bit digit range (0x30 to 0x39). |
| bool isDigit(int byte) => (byte & 0xf0) == kNum; |
| |
| // Converts a three-digit ASCII (UTF-8) representation of an octal number `xyz` to an integer. |
| int decodeOctal(int x, int y, int z) => (x & 0x3) << 6 | (y & 0x7) << 3 | z & 0x7; |
| |
| try { |
| final List<int> bytes = utf8.encode(line); |
| final List<int> out = <int>[]; |
| for (int i = 0; i < bytes.length;) { |
| if (bytes[i] != kBackslash || i > bytes.length - 4) { |
| // Unmapped byte: copy as-is. |
| out.add(bytes[i++]); |
| } else { |
| // Mapped byte: decode next 4 bytes. |
| if (bytes[i + 1] == kM && bytes[i + 2] == kCaret) { |
| // \M^x form: bytes in range 0x80 to 0x9f. |
| out.add((bytes[i + 3] & 0x7f) + 0x40); |
| } else if (bytes[i + 1] == kM && bytes[i + 2] == kDash) { |
| // \M-x form: bytes in range 0xa0 to 0xf7. |
| out.add(bytes[i + 3] | 0x80); |
| } else if (bytes.getRange(i + 1, i + 3).every(isDigit)) { |
| // \ddd form: octal representation (only used for \134 and \240). |
| out.add(decodeOctal(bytes[i + 1], bytes[i + 2], bytes[i + 3])); |
| } else { |
| // Unknown form: copy as-is. |
| out.addAll(bytes.getRange(0, 4)); |
| } |
| i += 4; |
| } |
| } |
| return utf8.decode(out); |
| } on Exception { |
| // Unable to decode line: return as-is. |
| return line; |
| } |
| } |
| |
| class IOSDeviceLogReader extends DeviceLogReader { |
| IOSDeviceLogReader._( |
| this._iMobileDevice, |
| this._majorSdkVersion, |
| this._deviceId, |
| this.name, |
| this._isWirelesslyConnected, |
| this._isCoreDevice, |
| String appName, |
| bool usingCISystem, { |
| bool forceXcodeDebug = false, |
| }) : // Match for lines for the runner in syslog. |
| // |
| // iOS 9 format: Runner[297] <Notice>: |
| // iOS 10 format: Runner(Flutter)[297] <Notice>: |
| _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: '), |
| _usingCISystem = usingCISystem, |
| _forceXcodeDebug = forceXcodeDebug; |
| |
| /// Create a new [IOSDeviceLogReader]. |
| factory IOSDeviceLogReader.create({ |
| required IOSDevice device, |
| IOSApp? app, |
| required IMobileDevice iMobileDevice, |
| bool usingCISystem = false, |
| }) { |
| final String appName = app?.name?.replaceAll('.app', '') ?? ''; |
| return IOSDeviceLogReader._( |
| iMobileDevice, |
| device.majorSdkVersion, |
| device.id, |
| device.name, |
| device.isWirelesslyConnected, |
| device.isCoreDevice, |
| appName, |
| usingCISystem, |
| forceXcodeDebug: device._platform.environment['FORCE_XCODE_DEBUG']?.toLowerCase() == 'true', |
| ); |
| } |
| |
| /// Create an [IOSDeviceLogReader] for testing. |
| factory IOSDeviceLogReader.test({ |
| required IMobileDevice iMobileDevice, |
| bool useSyslog = true, |
| bool usingCISystem = false, |
| int? majorSdkVersion, |
| bool isWirelesslyConnected = false, |
| bool isCoreDevice = false, |
| }) { |
| final int sdkVersion; |
| if (majorSdkVersion != null) { |
| sdkVersion = majorSdkVersion; |
| } else { |
| sdkVersion = useSyslog ? 12 : 13; |
| } |
| return IOSDeviceLogReader._( |
| iMobileDevice, sdkVersion, '1234', 'test', isWirelesslyConnected, isCoreDevice, 'Runner', usingCISystem); |
| } |
| |
| @override |
| final String name; |
| final int _majorSdkVersion; |
| final String _deviceId; |
| final bool _isWirelesslyConnected; |
| final bool _isCoreDevice; |
| final IMobileDevice _iMobileDevice; |
| final bool _usingCISystem; |
| |
| // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) |
| /// Whether XcodeDebug workflow is being forced. |
| final bool _forceXcodeDebug; |
| |
| // Matches a syslog line from the runner. |
| RegExp _runnerLineRegex; |
| |
| // Similar to above, but allows ~arbitrary components instead of "Runner" |
| // and "Flutter". The regex tries to strike a balance between not producing |
| // false positives and not producing false negatives. |
| final RegExp _anyLineRegex = RegExp(r'\w+(\([^)]*\))?\[\d+\] <[A-Za-z]+>: '); |
| |
| // Logging from native code/Flutter engine is prefixed by timestamp and process metadata: |
| // 2020-09-15 19:15:10.931434-0700 Runner[541:226276] Did finish launching. |
| // 2020-09-15 19:15:10.931434-0700 Runner[541:226276] [Category] Did finish launching. |
| // |
| // Logging from the dart code has no prefixing metadata. |
| final RegExp _debuggerLoggingRegex = RegExp(r'^\S* \S* \S*\[[0-9:]*] (.*)'); |
| |
| @visibleForTesting |
| late final StreamController<String> linesController = StreamController<String>.broadcast( |
| onListen: _listenToSysLog, |
| onCancel: dispose, |
| ); |
| |
| // Sometimes (race condition?) we try to send a log after the controller has |
| // been closed. See https://github.com/flutter/flutter/issues/99021 for more |
| // context. |
| @visibleForTesting |
| void addToLinesController(String message, IOSDeviceLogSource source) { |
| if (!linesController.isClosed) { |
| if (_excludeLog(message, source)) { |
| return; |
| } |
| linesController.add(message); |
| } |
| } |
| |
| /// Used to track messages prefixed with "flutter:" from the fallback log source. |
| final List<String> _fallbackStreamFlutterMessages = <String>[]; |
| |
| /// Used to track if a message prefixed with "flutter:" has been received from the primary log. |
| bool primarySourceFlutterLogReceived = false; |
| |
| /// There are three potential logging sources: `idevicesyslog`, `ios-deploy`, |
| /// and Unified Logging (Dart VM). When using more than one of these logging |
| /// sources at a time, prefer to use the primary source. However, if the |
| /// primary source is not working, use the fallback. |
| bool _excludeLog(String message, IOSDeviceLogSource source) { |
| // If no fallback, don't exclude any logs. |
| if (logSources.fallbackSource == null) { |
| return false; |
| } |
| |
| // If log is from primary source, don't exclude it unless the fallback was |
| // quicker and added the message first. |
| if (source == logSources.primarySource) { |
| if (!primarySourceFlutterLogReceived && message.startsWith('flutter:')) { |
| primarySourceFlutterLogReceived = true; |
| } |
| |
| // If the message was already added by the fallback, exclude it to |
| // prevent duplicates. |
| final bool foundAndRemoved = _fallbackStreamFlutterMessages.remove(message); |
| if (foundAndRemoved) { |
| return true; |
| } |
| return false; |
| } |
| |
| // If a flutter log was received from the primary source, that means it's |
| // working so don't use any messages from the fallback. |
| if (primarySourceFlutterLogReceived) { |
| return true; |
| } |
| |
| // When using logs from fallbacks, skip any logs not prefixed with "flutter:". |
| // This is done because different sources often have different prefixes for |
| // non-flutter messages, which makes duplicate matching difficult. Also, |
| // non-flutter messages are not critical for CI tests. |
| if (!message.startsWith('flutter:')) { |
| return true; |
| } |
| |
| _fallbackStreamFlutterMessages.add(message); |
| return false; |
| } |
| |
| final List<StreamSubscription<void>> _loggingSubscriptions = <StreamSubscription<void>>[]; |
| |
| @override |
| Stream<String> get logLines => linesController.stream; |
| |
| @override |
| FlutterVmService? get connectedVMService => _connectedVMService; |
| FlutterVmService? _connectedVMService; |
| |
| @override |
| set connectedVMService(FlutterVmService? connectedVmService) { |
| if (connectedVmService != null) { |
| _listenToUnifiedLoggingEvents(connectedVmService); |
| } |
| _connectedVMService = connectedVmService; |
| } |
| |
| static const int minimumUniversalLoggingSdkVersion = 13; |
| |
| /// Determine the primary and fallback source for device logs. |
| /// |
| /// There are three potential logging sources: `idevicesyslog`, `ios-deploy`, |
| /// and Unified Logging (Dart VM). |
| @visibleForTesting |
| _IOSDeviceLogSources get logSources { |
| // `ios-deploy` stopped working with iOS 17 / Xcode 15, so use `idevicesyslog` instead. |
| // However, `idevicesyslog` is sometimes unreliable so use Dart VM as a fallback. |
| // Also, `idevicesyslog` does not work with iOS 17 wireless devices, so use the |
| // Dart VM for wireless devices. |
| if (_isCoreDevice || _forceXcodeDebug) { |
| if (_isWirelesslyConnected) { |
| return _IOSDeviceLogSources( |
| primarySource: IOSDeviceLogSource.unifiedLogging, |
| ); |
| } |
| return _IOSDeviceLogSources( |
| primarySource: IOSDeviceLogSource.idevicesyslog, |
| fallbackSource: IOSDeviceLogSource.unifiedLogging, |
| ); |
| } |
| |
| // Use `idevicesyslog` for iOS 12 or less. |
| // Syslog stopped working on iOS 13 (https://github.com/flutter/flutter/issues/41133). |
| // However, from at least iOS 16, it has began working again. It's unclear |
| // why it started working again. |
| if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) { |
| return _IOSDeviceLogSources( |
| primarySource: IOSDeviceLogSource.idevicesyslog, |
| ); |
| } |
| |
| // Use `idevicesyslog` as a fallback to `ios-deploy` when debugging from |
| // CI system since sometimes `ios-deploy` does not return the device logs: |
| // https://github.com/flutter/flutter/issues/121231 |
| if (_usingCISystem && _majorSdkVersion >= 16) { |
| return _IOSDeviceLogSources( |
| primarySource: IOSDeviceLogSource.iosDeploy, |
| fallbackSource: IOSDeviceLogSource.idevicesyslog, |
| ); |
| } |
| |
| // Use `ios-deploy` to stream logs from the device when the device is not a |
| // CoreDevice and has iOS 13 or greater. |
| // When using `ios-deploy` and the Dart VM, prefer the more complete logs |
| // from the attached debugger, if available. |
| if (connectedVMService != null && (_iosDeployDebugger == null || !_iosDeployDebugger!.debuggerAttached)) { |
| return _IOSDeviceLogSources( |
| primarySource: IOSDeviceLogSource.unifiedLogging, |
| fallbackSource: IOSDeviceLogSource.iosDeploy, |
| ); |
| } |
| return _IOSDeviceLogSources( |
| primarySource: IOSDeviceLogSource.iosDeploy, |
| fallbackSource: IOSDeviceLogSource.unifiedLogging, |
| ); |
| } |
| |
| /// Whether `idevicesyslog` is used as either the primary or fallback source for device logs. |
| @visibleForTesting |
| bool get useSyslogLogging { |
| return logSources.primarySource == IOSDeviceLogSource.idevicesyslog || |
| logSources.fallbackSource == IOSDeviceLogSource.idevicesyslog; |
| } |
| |
| /// Whether the Dart VM is used as either the primary or fallback source for device logs. |
| /// |
| /// Unified Logging only works after the Dart VM has been connected to. |
| @visibleForTesting |
| bool get useUnifiedLogging { |
| return logSources.primarySource == IOSDeviceLogSource.unifiedLogging || |
| logSources.fallbackSource == IOSDeviceLogSource.unifiedLogging; |
| } |
| |
| |
| /// Whether `ios-deploy` is used as either the primary or fallback source for device logs. |
| @visibleForTesting |
| bool get useIOSDeployLogging { |
| return logSources.primarySource == IOSDeviceLogSource.iosDeploy || |
| logSources.fallbackSource == IOSDeviceLogSource.iosDeploy; |
| } |
| |
| /// Listen to Dart VM for logs on iOS 13 or greater. |
| Future<void> _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async { |
| if (!useUnifiedLogging) { |
| return; |
| } |
| try { |
| // The VM service will not publish logging events unless the debug stream is being listened to. |
| // Listen to this stream as a side effect. |
| unawaited(connectedVmService.service.streamListen('Debug')); |
| |
| await Future.wait(<Future<void>>[ |
| connectedVmService.service.streamListen(vm_service.EventStreams.kStdout), |
| connectedVmService.service.streamListen(vm_service.EventStreams.kStderr), |
| ]); |
| } on vm_service.RPCError { |
| // Do nothing, since the tool is already subscribed. |
| } |
| |
| void logMessage(vm_service.Event event) { |
| final String message = processVmServiceMessage(event); |
| if (message.isNotEmpty) { |
| addToLinesController(message, IOSDeviceLogSource.unifiedLogging); |
| } |
| } |
| |
| _loggingSubscriptions.addAll(<StreamSubscription<void>>[ |
| connectedVmService.service.onStdoutEvent.listen(logMessage), |
| connectedVmService.service.onStderrEvent.listen(logMessage), |
| ]); |
| } |
| |
| /// Log reader will listen to [debugger.logLines] and will detach debugger on dispose. |
| IOSDeployDebugger? get debuggerStream => _iosDeployDebugger; |
| |
| /// Send messages from ios-deploy debugger stream to device log reader stream. |
| set debuggerStream(IOSDeployDebugger? debugger) { |
| // Logging is gathered from syslog on iOS earlier than 13. |
| if (!useIOSDeployLogging) { |
| return; |
| } |
| _iosDeployDebugger = debugger; |
| if (debugger == null) { |
| return; |
| } |
| // Add the debugger logs to the controller created on initialization. |
| _loggingSubscriptions.add(debugger.logLines.listen( |
| (String line) => addToLinesController( |
| _debuggerLineHandler(line), |
| IOSDeviceLogSource.iosDeploy, |
| ), |
| onError: linesController.addError, |
| onDone: linesController.close, |
| cancelOnError: true, |
| )); |
| } |
| IOSDeployDebugger? _iosDeployDebugger; |
| |
| // Strip off the logging metadata (leave the category), or just echo the line. |
| String _debuggerLineHandler(String line) => _debuggerLoggingRegex.firstMatch(line)?.group(1) ?? line; |
| |
| /// Start and listen to idevicesyslog to get device logs for iOS versions |
| /// prior to 13 or if [useBothLogDeviceReaders] is true. |
| void _listenToSysLog() { |
| if (!useSyslogLogging) { |
| return; |
| } |
| _iMobileDevice.startLogger(_deviceId).then<void>((Process process) { |
| process.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler()); |
| process.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler()); |
| process.exitCode.whenComplete(() { |
| if (!linesController.hasListener) { |
| return; |
| } |
| // When using both log readers, do not close the stream on exit. |
| // This is to allow ios-deploy to be the source of authority to close |
| // the stream. |
| if (useSyslogLogging && useIOSDeployLogging && debuggerStream != null) { |
| return; |
| } |
| linesController.close(); |
| }); |
| assert(idevicesyslogProcess == null); |
| idevicesyslogProcess = process; |
| }); |
| } |
| |
| @visibleForTesting |
| Process? idevicesyslogProcess; |
| |
| // Returns a stateful line handler to properly capture multiline output. |
| // |
| // For multiline log messages, any line after the first is logged without |
| // any specific prefix. To properly capture those, we enter "printing" mode |
| // after matching a log line from the runner. When in printing mode, we print |
| // all lines until we find the start of another log message (from any app). |
| void Function(String line) _newSyslogLineHandler() { |
| bool printing = false; |
| |
| return (String line) { |
| if (printing) { |
| if (!_anyLineRegex.hasMatch(line)) { |
| addToLinesController(decodeSyslog(line), IOSDeviceLogSource.idevicesyslog); |
| return; |
| } |
| |
| printing = false; |
| } |
| |
| final Match? match = _runnerLineRegex.firstMatch(line); |
| |
| if (match != null) { |
| final String logLine = line.substring(match.end); |
| // Only display the log line after the initial device and executable information. |
| addToLinesController(decodeSyslog(logLine), IOSDeviceLogSource.idevicesyslog); |
| printing = true; |
| } |
| }; |
| } |
| |
| @override |
| void dispose() { |
| for (final StreamSubscription<void> loggingSubscription in _loggingSubscriptions) { |
| loggingSubscription.cancel(); |
| } |
| idevicesyslogProcess?.kill(); |
| _iosDeployDebugger?.detach(); |
| } |
| } |
| |
| enum IOSDeviceLogSource { |
| /// Gets logs from ios-deploy debugger. |
| iosDeploy, |
| /// Gets logs from idevicesyslog. |
| idevicesyslog, |
| /// Gets logs from the Dart VM Service. |
| unifiedLogging, |
| } |
| |
| class _IOSDeviceLogSources { |
| _IOSDeviceLogSources({ |
| required this.primarySource, |
| this.fallbackSource, |
| }); |
| |
| final IOSDeviceLogSource primarySource; |
| final IOSDeviceLogSource? fallbackSource; |
| } |
| |
| /// A [DevicePortForwarder] specialized for iOS usage with iproxy. |
| class IOSDevicePortForwarder extends DevicePortForwarder { |
| |
| /// Create a new [IOSDevicePortForwarder]. |
| IOSDevicePortForwarder({ |
| required Logger logger, |
| required String id, |
| required IProxy iproxy, |
| required OperatingSystemUtils operatingSystemUtils, |
| }) : _logger = logger, |
| _id = id, |
| _iproxy = iproxy, |
| _operatingSystemUtils = operatingSystemUtils; |
| |
| /// Create a [IOSDevicePortForwarder] for testing. |
| /// |
| /// This specifies the path to iproxy as 'iproxy` and the dyLdLibEntry as |
| /// 'DYLD_LIBRARY_PATH: /path/to/libs'. |
| /// |
| /// The device id may be provided, but otherwise defaults to '1234'. |
| factory IOSDevicePortForwarder.test({ |
| required ProcessManager processManager, |
| required Logger logger, |
| String? id, |
| required OperatingSystemUtils operatingSystemUtils, |
| }) { |
| return IOSDevicePortForwarder( |
| logger: logger, |
| iproxy: IProxy.test( |
| logger: logger, |
| processManager: processManager, |
| ), |
| id: id ?? '1234', |
| operatingSystemUtils: operatingSystemUtils, |
| ); |
| } |
| |
| final Logger _logger; |
| final String _id; |
| final IProxy _iproxy; |
| final OperatingSystemUtils _operatingSystemUtils; |
| |
| @override |
| List<ForwardedPort> forwardedPorts = <ForwardedPort>[]; |
| |
| @visibleForTesting |
| void addForwardedPorts(List<ForwardedPort> ports) { |
| ports.forEach(forwardedPorts.add); |
| } |
| |
| static const Duration _kiProxyPortForwardTimeout = Duration(seconds: 1); |
| |
| @override |
| Future<int> forward(int devicePort, { int? hostPort }) async { |
| final bool autoselect = hostPort == null || hostPort == 0; |
| if (autoselect) { |
| final int freePort = await _operatingSystemUtils.findFreePort(); |
| // Dynamic port range 49152 - 65535. |
| hostPort = freePort == 0 ? 49152 : freePort; |
| } |
| |
| Process? process; |
| |
| bool connected = false; |
| while (!connected) { |
| _logger.printTrace('Attempting to forward device port $devicePort to host port $hostPort'); |
| process = await _iproxy.forward(devicePort, hostPort!, _id); |
| // TODO(ianh): This is a flaky race condition, https://github.com/libimobiledevice/libimobiledevice/issues/674 |
| connected = !await process.stdout.isEmpty.timeout(_kiProxyPortForwardTimeout, onTimeout: () => false); |
| if (!connected) { |
| process.kill(); |
| if (autoselect) { |
| hostPort += 1; |
| if (hostPort > 65535) { |
| throw Exception('Could not find open port on host.'); |
| } |
| } else { |
| throw Exception('Port $hostPort is not available.'); |
| } |
| } |
| } |
| assert(connected); |
| assert(process != null); |
| |
| final ForwardedPort forwardedPort = ForwardedPort.withContext( |
| hostPort!, devicePort, process, |
| ); |
| _logger.printTrace('Forwarded port $forwardedPort'); |
| forwardedPorts.add(forwardedPort); |
| return hostPort; |
| } |
| |
| @override |
| Future<void> unforward(ForwardedPort forwardedPort) async { |
| if (!forwardedPorts.remove(forwardedPort)) { |
| // Not in list. Nothing to remove. |
| return; |
| } |
| |
| _logger.printTrace('Un-forwarding port $forwardedPort'); |
| forwardedPort.dispose(); |
| } |
| |
| @override |
| Future<void> dispose() async { |
| for (final ForwardedPort forwardedPort in forwardedPorts) { |
| forwardedPort.dispose(); |
| } |
| } |
| } |