blob: e7ef959149ef7972fa28a47ecec2e32c0d70d401 [file]
// 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.
/// @docImport '../resident_runner.dart';
library;
import 'dart:async';
import 'package:meta/meta.dart';
import 'package:process/process.dart';
import 'package:unified_analytics/unified_analytics.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 '../darwin/darwin.dart';
import '../device.dart';
import '../device_port_forwarder.dart';
import '../device_vm_service_discovery_for_attach.dart';
import '../features.dart';
import '../globals.dart' as globals;
import '../macos/xcdevice.dart';
import '../macos/xcode.dart';
import '../mdns_discovery.dart';
import '../project.dart';
import '../protocol_discovery.dart';
import '../vmservice.dart';
import 'application_package.dart';
import 'core_devices.dart';
import 'device_support.dart';
import 'ios_deploy.dart';
import 'ios_workflow.dart';
import 'iproxy.dart';
import 'lldb.dart';
import 'mac.dart';
import 'xcode_build_settings.dart';
import 'xcode_debug.dart';
import 'xcodeproj.dart';
const kJITCrashLogInterceptorIdentifier = 'jit_crash_log';
const kJITCrashFailureMessage =
'Crash occurred when compiling unknown function in unoptimized JIT mode in unknown pass';
@visibleForTesting
String jitCrashFailureInstructions(String deviceVersion) =>
'''
════════════════════════════════════════════════════════════════════════════════
A change to iOS has caused a temporary break in Flutter's debug mode on
physical devices.
See https://github.com/flutter/flutter/issues/163984 for details.
In the meantime, we recommend these temporary workarounds:
* When developing with a physical device, use one running iOS 18.3 or lower.
* Use a simulator for development rather than a physical device.
* If you must use a device updated to $deviceVersion, use Flutter's release or
profile mode via --release or --profile flags.
════════════════════════════════════════════════════════════════════════════════''';
/// The error message shown when an iOS app fails to launch because it has not
/// been migrated to the UIScene lifecycle.
const String kUISceneMigrationRequiredError = '''
════════════════════════════════════════════════════════════════════════════════
Your iOS app has not been migrated to the UIScene lifecycle.
UIScene lifecycle is required on iOS 27 and later.
To migrate your app, please follow the migration guide at:
https://flutter.dev/to/uiscene-migration
════════════════════════════════════════════════════════════════════════════════''';
enum IOSDeploymentMethod {
iosDeployLaunch,
iosDeployLaunchAndAttach,
coreDeviceWithoutDebugger,
coreDeviceWithLLDB,
coreDeviceWithXcode,
coreDeviceWithXcodeFallback,
}
class IOSDevices extends PollingDeviceDiscovery {
IOSDevices({
required this._platform,
required this.xcdevice,
required this._iosWorkflow,
required this._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 _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 _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;
}
// 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;
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 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;
// 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();
_observedDeviceEventsSubscription = null;
xcdevice.cancelWirelessDiscovery();
}
@override
Future<void> cancelWirelessDiscovery() async {
xcdevice.cancelWirelessDiscovery();
}
@override
Future<List<Device>> pollingGetDevices({
Duration? timeout,
bool forWirelessDiscovery = false,
}) async {
if (!_platform.isMacOS) {
throw UnsupportedError('Control of iOS devices or simulators only supported on macOS.');
}
if (forWirelessDiscovery) {
return xcdevice.getAvailableIOSDevicesForWirelessDiscovery(timeout: timeout);
}
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 this._fileSystem,
required this._fileSystemUtils,
required this._processUtils,
required this.name,
required this._cpuArch,
this._cpuArchitectureString,
required this.connectionInterface,
required this.isConnected,
required this.isPaired,
required this.devModeEnabled,
required this.isCoreDevice,
this._sdkVersion,
this._modelCode,
this._operatingSystemVersion,
required this._platform,
required this._iosDeploy,
required this._iMobileDevice,
required this._coreDeviceControl,
required this._coreDeviceLauncher,
required this._xcodeDebug,
required IProxy iProxy,
required super.logger,
required this._analytics,
required this._xcode,
}) : _iproxy = iProxy,
_logger = logger,
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 String? _modelCode;
final String? _operatingSystemVersion;
final IOSDeploy _iosDeploy;
final Analytics _analytics;
final FileSystem _fileSystem;
final FileSystemUtils _fileSystemUtils;
final ProcessUtils _processUtils;
final Logger _logger;
final Platform _platform;
final IMobileDevice _iMobileDevice;
final IOSCoreDeviceControl _coreDeviceControl;
final IOSCoreDeviceLauncher _coreDeviceLauncher;
final XcodeDebug _xcodeDebug;
final Xcode? _xcode;
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;
}
late final IOSDeviceSupport deviceSupport = IOSDeviceSupport(
logger: _logger,
processUtils: _processUtils,
xcode: _xcode,
homeDirectory: _fileSystemUtils.homeDirPath == null
? null
: _fileSystem.directory(_fileSystemUtils.homeDirPath),
modelCode: _modelCode,
operatingSystemVersion: _operatingSystemVersion,
cpuArchitectureString: _cpuArchitectureString,
deviceId: id,
);
@override
final String name;
@override
bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease;
final CpuArch _cpuArch;
final String? _cpuArchitectureString;
@override
Future<CpuArch> get cpuArch async => _cpuArch;
@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;
bool devModeEnabled = false;
/// Device has trusted this computer and paired.
bool isPaired = false;
/// CoreDevice is a device connectivity stack introduced in Xcode 15. Devices
/// with iOS 17 or greater are CoreDevices.
final bool isCoreDevice;
final _logReaders = <IOSApp?, DeviceLogReader>{};
DevicePortForwarder? _portForwarder;
@visibleForTesting
IOSDeployDebugger? iosDeployDebugger;
@override
Future<bool> get isLocalEmulator async => false;
@override
Future<String?> get emulatorId async => null;
@override
bool get supportsStartPaused => false;
@override
bool get supportsFlavors => true;
@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) {
final (bool installSuccess, _) = await _coreDeviceControl.installApp(
deviceId: id,
bundlePath: bundle.path,
);
installationResult = installSuccess ? 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.
Future<bool> isSupported() async => _cpuArch == .arm64;
@override
Future<LaunchResult> startApp(
IOSApp package, {
String? mainPath,
String? route,
required DebuggingOptions debuggingOptions,
Map<String, Object?> platformArgs = const <String, Object?>{},
bool prebuiltApplication = 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',
);
}
warnIfSlowWirelessDebugging(debuggingOptions);
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: _cpuArch,
deviceID: id,
disablePortPublication:
debuggingOptions.usingCISystem && debuggingOptions.disablePortPublication,
);
if (!buildResult.success) {
_logger.printError('Could not build the precompiled application for the device.');
await diagnoseXcodeBuildFailure(
buildResult,
analytics: _analytics,
fileSystem: globals.fs,
logger: globals.logger,
platform: FlutterDarwinPlatform.ios,
project: package.project.parent,
device: this,
);
_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();
}
final bool shouldAttachDebugger = shouldAttachLLDBDebugger(debuggingOptions);
if (shouldAttachDebugger) {
await deviceSupport.prepareDeviceSupport();
}
// Step 3: Attempt to install the application on the device.
final List<String> launchArguments = debuggingOptions.getIOSLaunchArguments(
EnvironmentType.physical,
route,
platformArgs,
interfaceType: connectionInterface,
);
Status startAppStatus = _logger.startProgress('Installing and launching...');
IOSDeploymentMethod? deploymentMethod;
try {
ProtocolDiscovery? vmServiceDiscovery;
var installationResult = 1;
final DeviceLogReader deviceLogReader = getLogReader(
app: package,
usingCISystem: debuggingOptions.usingCISystem,
);
// This completer is used to intercept when the app crashes after the app launches but
// before the Dart VM service is found.
final appTerminatedCompleter = Completer<Uri?>();
if (deviceLogReader is SharedIOSDeviceLogReader) {
await _addLogInterceptors(deviceLogReader, appTerminatedCompleter);
}
if (debuggingOptions.debuggingEnabled) {
_logger.printTrace('Debugging is enabled, connecting to vmService');
vmServiceDiscovery = _setupDebuggerAndVmServiceDiscovery(
package: package,
bundle: bundle,
debuggingOptions: debuggingOptions,
launchArguments: launchArguments,
uninstallFirst: debuggingOptions.uninstallFirst,
);
}
if (isCoreDevice) {
final (
bool result,
IOSDeploymentMethod coreDeviceDeploymentMethod,
) = await _startAppOnCoreDevice(
debuggingOptions: debuggingOptions,
package: package,
launchArguments: launchArguments,
mainPath: mainPath,
discoveryTimeout: discoveryTimeout,
shutdownHooks: shutdownHooks ?? globals.shutdownHooks,
shouldAttachDebugger: shouldAttachDebugger,
);
installationResult = result ? 0 : 1;
deploymentMethod = coreDeviceDeploymentMethod;
} else if (iosDeployDebugger == null) {
deploymentMethod = IOSDeploymentMethod.iosDeployLaunch;
installationResult = await _iosDeploy.launchApp(
deviceId: id,
bundlePath: bundle.path,
appDeltaDirectory: package.appDeltaDirectory,
launchArguments: launchArguments,
interfaceType: connectionInterface,
uninstallFirst: debuggingOptions.uninstallFirst,
);
} else {
deploymentMethod = IOSDeploymentMethod.iosDeployLaunchAndAttach;
installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1;
}
if (installationResult != 0) {
_analytics.send(
Event.appleUsageEvent(
workflow: 'ios-physical-deployment',
parameter: deploymentMethod.name,
result: 'launch failed',
),
);
_printInstallError(bundle);
await dispose();
return LaunchResult.failed();
}
if (!debuggingOptions.debuggingEnabled) {
_analytics.send(
Event.appleUsageEvent(
workflow: 'ios-physical-deployment',
parameter: deploymentMethod.name,
result: 'release success',
),
);
return LaunchResult.succeeded();
}
_logger.printTrace('Application launched on the device. Waiting for Dart VM Service url.');
final int defaultTimeout;
if (isCoreDevice && 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(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 (isWirelesslyConnected) {
_logger.printError(
'\nYour debugging device seems wirelessly connected. '
'Consider plugging it in and trying again.',
);
_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 (isCoreDevice) {
localUri = await _discoverDartVMForCoreDevice(
debuggingOptions: debuggingOptions,
packageId: packageId,
vmServiceDiscovery: vmServiceDiscovery,
package: package,
deviceLogReader: deviceLogReader,
appTerminatedCompleter: appTerminatedCompleter,
);
} else if (isWirelesslyConnected) {
// Wait for the Dart VM url to be discovered via logs (from `ios-deploy`)
// in ProtocolDiscovery. Then via mDNS, construct the Dart VM url using
// the device IP as the host by finding Dart VM services matching the
// app bundle id and Dart VM port.
// Wait for Dart VM Service to start up.
final Uri? serviceURL = await vmServiceDiscovery?.uri;
if (serviceURL == null) {
await iosDeployDebugger?.stopAndDumpBacktrace();
await dispose();
_analytics.send(
Event.appleUsageEvent(
workflow: 'ios-physical-deployment',
parameter: deploymentMethod.name,
result: 'wireless debugging failed',
),
);
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 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: debuggingOptions.ipv6,
deviceVmservicePort: serviceURL.port,
useDeviceIPAsHost: true,
);
mDNSLookupTimer.cancel();
} else {
localUri = await vmServiceDiscovery?.uri;
// If the `ios-deploy` debugger loses connection before it finds the
// Dart Service VM url, try starting the debugger and launching the
// app again.
if (localUri == null &&
debuggingOptions.usingCISystem &&
iosDeployDebugger != null &&
iosDeployDebugger!.lostConnection) {
_logger.printStatus('Lost connection to device. Trying to connect again...');
await dispose();
vmServiceDiscovery = _setupDebuggerAndVmServiceDiscovery(
package: package,
bundle: bundle,
debuggingOptions: debuggingOptions,
launchArguments: launchArguments,
uninstallFirst: false,
skipInstall: true,
);
installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1;
if (installationResult != 0) {
_printInstallError(bundle);
await dispose();
return LaunchResult.failed();
}
localUri = await vmServiceDiscovery.uri;
}
}
timer.cancel();
if (localUri == null) {
await iosDeployDebugger?.stopAndDumpBacktrace();
await dispose();
_analytics.send(
Event.appleUsageEvent(
workflow: 'ios-physical-deployment',
parameter: deploymentMethod.name,
result: 'debugging failed',
),
);
return LaunchResult.failed();
}
_analytics.send(
Event.appleUsageEvent(
workflow: 'ios-physical-deployment',
parameter: deploymentMethod.name,
result: 'debugging success',
),
);
return LaunchResult.succeeded(vmServiceUri: localUri);
} on ProcessException catch (e) {
await iosDeployDebugger?.stopAndDumpBacktrace();
_logger.printError(e.message);
await dispose();
if (deploymentMethod != null) {
_analytics.send(
Event.appleUsageEvent(
workflow: 'ios-physical-deployment',
parameter: deploymentMethod.name,
result: 'process exception',
),
);
}
return LaunchResult.failed();
} finally {
startAppStatus.stop();
if (isCoreDevice && debuggingOptions.debuggingEnabled && package is BuildableIOSApp) {
// When debugging via Xcode, after the app launches, 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.current(),
buildInfo: debuggingOptions.buildInfo,
targetOverride: mainPath,
);
}
}
}
@visibleForTesting
void warnIfSlowWirelessDebugging(DebuggingOptions debuggingOptions) {
// The minimum iOS version where wireless debugging is known to be slow.
const minSlowWirelessDebugIOSVersion = 26;
final Version? sdkVersion = this.sdkVersion;
if (!isWirelesslyConnected ||
!debuggingOptions.debuggingEnabled ||
sdkVersion == null ||
sdkVersion.major < minSlowWirelessDebugIOSVersion) {
return;
}
final warningMessage =
'Wireless debugging on iOS ${sdkVersion.major} may be slower than expected. '
'For better performance, consider using a wired (USB) connection.';
_logger.printWarning(warningMessage);
_logger.sendEvent('app.warning', <String, Object?>{
'warningId': 'ios-wireless-slow',
'warning': warningMessage,
'category': 'ios-wireless-performance',
'deviceId': id,
'deviceOsVersion': sdkVersion.major,
'actionable': true,
});
}
void _printInstallError(Directory bundle) {
_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('');
}
Future<void> _addLogInterceptors(
SharedIOSDeviceLogReader deviceLogReader,
Completer<Uri?> appTerminatedCompleter,
) async {
final String? uisceneWarning = globals.userMessages.uiSceneMigrationWarning;
if (uisceneWarning != null) {
final uisceneWarningInterceptor = LogInterceptor(
identifier: 'uiscene_requirement',
pattern: RegExp(
'`UIScene` lifecycle will soon be required|This process does not adopt UIScene lifecycle',
),
action: (String message) {
_logger.printWarning(uisceneWarning);
},
excludeFromStream: true,
);
deviceLogReader.addLogInterceptor(uisceneWarningInterceptor);
}
final uisceneCrashInterceptor = LogInterceptor(
identifier: 'uiscene_crash',
pattern: RegExp(r'UIScene life\s?cycle is required'),
action: (String message) {
throwToolExit(kUISceneMigrationRequiredError);
},
excludeFromStream: false,
);
deviceLogReader.addLogInterceptor(uisceneCrashInterceptor);
final LogInterceptor? jitCrashInterceptor = await _jitCrashInterceptor();
if (jitCrashInterceptor != null) {
deviceLogReader.addLogInterceptor(jitCrashInterceptor);
}
final appTerminatedInterceptor = LogInterceptor(
identifier: 'app_terminated',
pattern: RegExp('^App terminated due to signal'),
action: (String message) {
if (!appTerminatedCompleter.isCompleted) {
appTerminatedCompleter.complete();
}
},
excludeFromStream: false,
);
deviceLogReader.addLogInterceptor(appTerminatedInterceptor);
final missingSymbolsInterceptor = LogInterceptor(
identifier: 'missing_symbols',
pattern: LLDB.missingSymbolsPattern,
action: (String message) {
_logger.printTrace(message);
final String? warning = deviceSupport.missingSymbolsWarning(warnWhenSymbolsExist: true);
if (warning != null) {
_logger.printWarning(warning);
}
},
excludeFromStream: true,
);
deviceLogReader.addLogInterceptor(missingSymbolsInterceptor);
}
/// Find the Dart VM url using ProtocolDiscovery (logs from `idevicesyslog`)
/// and mDNS simultaneously, using whichever is found first. `idevicesyslog`
/// does not work on wireless devices, so only use mDNS for wireless devices.
/// Wireless devices require using the device IP as the host.
Future<Uri?> _discoverDartVMForCoreDevice({
required String packageId,
required DebuggingOptions debuggingOptions,
ProtocolDiscovery? vmServiceDiscovery,
IOSApp? package,
required DeviceLogReader deviceLogReader,
required Completer<Uri?> appTerminatedCompleter,
}) async {
final bool discoverVMUrlFromLogs = vmServiceDiscovery != null && !isWirelesslyConnected;
// If mDNS fails, don't throw since url may still be findable through vmServiceDiscovery.
final Future<Uri?> vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch(
packageId,
this,
usesIpv6: debuggingOptions.ipv6,
useDeviceIPAsHost: isWirelesslyConnected,
throwOnMissingLocalNetworkPermissionsError: !discoverVMUrlFromLogs,
);
final discoveryOptions = <Future<Uri?>>[
vmUrlFromMDns,
// vmServiceDiscovery uses device logs (`idevicesyslog`), which doesn't work
// on wireless devices.
if (discoverVMUrlFromLogs) vmServiceDiscovery.uri,
];
Uri? localUri = await Future.any(<Future<Uri?>>[
...discoveryOptions,
appTerminatedCompleter.future,
]);
// If the first future to return is null, wait for the other to complete
// unless the app was terminated.
if (localUri == null && !appTerminatedCompleter.isCompleted) {
final Future<List<Uri?>> allDiscoveryOptionsComplete = Future.wait(discoveryOptions);
await Future.any(<Future<Object?>>[
allDiscoveryOptionsComplete,
appTerminatedCompleter.future,
]);
if (!appTerminatedCompleter.isCompleted) {
// If it wasn't terminated, that means one of the discovery options completed.
final List<Uri?> vmUrls = await allDiscoveryOptionsComplete;
localUri = vmUrls.where((Uri? vmUrl) => vmUrl != null).firstOrNull;
}
}
if (deviceLogReader is SharedIOSDeviceLogReader) {
deviceLogReader.removeLogInterceptorByIdentifier(kJITCrashLogInterceptorIdentifier);
}
return localUri;
}
/// Listen to device logs for crash on iOS 18.4+ due to JIT restriction. If
/// found, give guided error and throw tool exit. Returns null and does not
/// listen if device is less than iOS 18.4.
Future<LogInterceptor?> _jitCrashInterceptor() async {
// Currently only checking for kJITCrashFailureMessage, which only should
// be checked on iOS 18.4+.
if (sdkVersion == null || sdkVersion! < Version(18, 4, null)) {
return null;
}
final String deviceSdkVersion = await sdkNameAndVersion;
return LogInterceptor(
identifier: kJITCrashLogInterceptorIdentifier,
pattern: kJITCrashFailureMessage,
action: (String message) {
throwToolExit(jitCrashFailureInstructions(deviceSdkVersion));
},
excludeFromStream: false,
);
}
ProtocolDiscovery _setupDebuggerAndVmServiceDiscovery({
required IOSApp package,
required Directory bundle,
required DebuggingOptions debuggingOptions,
required List<String> launchArguments,
required bool uninstallFirst,
bool skipInstall = false,
}) {
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: uninstallFirst,
skipInstall: skipInstall,
);
if (deviceLogReader is IOSDeviceLogReader) {
deviceLogReader.debuggerStream = iosDeployDebugger;
}
}
// Don't port forward if debugging with a wireless device.
return ProtocolDiscovery.vmService(
deviceLogReader,
portForwarder: isWirelesslyConnected ? null : portForwarder,
hostPort: debuggingOptions.hostVmServicePort,
devicePort: debuggingOptions.deviceVmServicePort,
ipv6: debuggingOptions.ipv6,
logger: _logger,
);
}
/// Whether the LLDB debugger should be attached.
///
/// The LLDB debugger should only be attached in debug mode or if the user uses the
/// `--ios-profile-debugger` flag in profile mode.
bool shouldAttachLLDBDebugger(DebuggingOptions debuggingOptions) {
return debuggingOptions.buildInfo.isDebug ||
(debuggingOptions.buildInfo.isProfile && (debuggingOptions.iosProfileDebugger ?? false));
}
/// Uses either `devicectl` or Xcode automation to install, launch, and debug
/// apps on physical iOS devices.
///
/// 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` lacked the ability to start a `debugserver` and therefore `ptrace`,
/// which are needed for debug mode due to using a JIT Dart VM.
///
/// Xcode 16 introduced a command to lldb that allows you to start a debugserver, which
/// can be used in unison with `devicectl`.
///
/// Therefore, when starting an app on a CoreDevice, use `devicectl` when
/// debugging is not enabled. If using Xcode 16, use `devicectl` and `lldb`.
/// Otherwise use Xcode automation.
Future<(bool, IOSDeploymentMethod)> _startAppOnCoreDevice({
required DebuggingOptions debuggingOptions,
required IOSApp package,
required List<String> launchArguments,
required String? mainPath,
required bool shouldAttachDebugger,
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, IOSDeploymentMethod.coreDeviceWithoutDebugger);
}
// Launch app to device
final IOSCoreDeviceLaunchResult? launchResult = await _coreDeviceControl.launchApp(
deviceId: id,
bundleId: package.id,
launchArguments: launchArguments,
);
final bool launchSuccess = launchResult != null && launchResult.outcome == 'success';
return (launchSuccess, IOSDeploymentMethod.coreDeviceWithoutDebugger);
}
IOSDeploymentMethod? deploymentMethod;
// Xcode 16 introduced a way to start and attach to a debugserver through LLDB.
// However, it doesn't work reliably until Xcode 26.
// Use LLDB if Xcode version is greater than 26 and the feature is enabled.
final Version? xcodeVersion = globals.xcode?.currentVersion;
if (xcodeVersion != null && xcodeVersion.major >= 26 && featureFlags.isLLDBDebuggingEnabled) {
final DeviceLogReader deviceLogReader = getLogReader(
app: package,
usingCISystem: debuggingOptions.usingCISystem,
);
if (deviceLogReader is IOSDeviceLogReader) {
await deviceLogReader.listenToCoreDeviceLauncher(_coreDeviceLauncher);
}
if (shouldAttachDebugger) {
final bool launchSuccess = await _coreDeviceLauncher.launchAppWithLLDBDebugger(
deviceId: id,
deviceSupport: deviceSupport,
bundlePath: package.deviceBundlePath,
bundleId: package.id,
launchArguments: launchArguments,
shutdownHooks: globals.shutdownHooks,
mode: debuggingOptions.buildInfo.mode,
);
if (launchSuccess) {
return (launchSuccess, IOSDeploymentMethod.coreDeviceWithLLDB);
} else {
deploymentMethod = IOSDeploymentMethod.coreDeviceWithXcodeFallback;
_analytics.send(
Event.appleUsageEvent(
workflow: 'ios-physical-deployment',
parameter: IOSDeploymentMethod.coreDeviceWithLLDB.name,
result: 'launch failed',
),
);
}
} else {
final bool launchSuccess = await _coreDeviceLauncher.launchAppAndStreamLogsWithoutDebugger(
deviceId: id,
bundlePath: package.deviceBundlePath,
bundleId: package.id,
launchArguments: launchArguments,
shutdownHooks: globals.shutdownHooks,
);
if (launchSuccess) {
return (launchSuccess, IOSDeploymentMethod.coreDeviceWithoutDebugger);
} else {
deploymentMethod = IOSDeploymentMethod.coreDeviceWithXcodeFallback;
_analytics.send(
Event.appleUsageEvent(
workflow: 'ios-physical-deployment',
parameter: IOSDeploymentMethod.coreDeviceWithoutDebugger.name,
result: 'launch failed',
),
);
}
}
}
deploymentMethod ??= IOSDeploymentMethod.coreDeviceWithXcode;
// If LLDB is not available or fails, fallback to using Xcode.
_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 launchTimeout = isWirelesslyConnected ? 45 : 30;
final timer = Timer(discoveryTimeout ?? Duration(seconds: launchTimeout), () {
_logger.printError(
'Xcode is taking longer than expected to start debugging the app. '
'If the issue persists, try closing Xcode and re-running your Flutter command.',
);
});
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, deploymentMethod);
}
if (project.xcodeWorkspace == null) {
globals.printError('Unable to get Xcode workspace.');
return (false, deploymentMethod);
}
final String? scheme = projectInfo.schemeFor(debuggingOptions.buildInfo);
if (scheme == null) {
projectInfo.reportFlavorNotFoundAndExit();
}
_xcodeDebug.ensureXcodeDebuggerLaunchAction(project.xcodeProjectSchemeFile(scheme: scheme));
debugProject = XcodeDebugProject(
scheme: scheme,
xcodeProject: project.xcodeProject,
xcodeWorkspace: project.xcodeWorkspace!,
hostAppProjectName: project.hostAppProjectName,
expectedConfigurationBuildDir: bundle.parent.absolute.path,
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, deploymentMethod);
}
// Core Devices (iOS 17 devices) are debugged through Xcode so don't
// include these flags, which are used to check if the app was launched
// via Flutter CLI and `ios-deploy`.
final List<String> filteredLaunchArguments = launchArguments
.where((String arg) => arg != '--enable-checked-mode' && arg != '--verify-entry-points')
.toList();
final bool debugSuccess = await _xcodeDebug.debugApp(
project: debugProject,
deviceId: id,
launchArguments: filteredLaunchArguments,
);
timer.cancel();
// Kill Xcode on shutdown when running from CI
if (debuggingOptions.usingCISystem) {
shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true));
}
return (debugSuccess, deploymentMethod);
}
@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 _coreDeviceLauncher.stopApp(deviceId: id);
}
@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,
xcode: globals.xcode,
),
);
}
@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
VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
String? appId,
String? fuchsiaModule,
int? filterDevicePort,
int? expectedHostPort,
required bool ipv6,
required Logger logger,
}) {
final bool compatibleWithProtocolDiscovery =
majorSdkVersion < IOSDeviceLogReader.minimumUniversalLoggingSdkVersion &&
!isWirelesslyConnected;
final mdnsVMServiceDiscoveryForAttach = MdnsVMServiceDiscoveryForAttach(
device: this,
appId: appId,
deviceVmservicePort: filterDevicePort,
hostVmservicePort: expectedHostPort,
usesIpv6: ipv6,
useDeviceIPAsHost: isWirelesslyConnected,
);
if (compatibleWithProtocolDiscovery) {
return DelegateVMServiceDiscoveryForAttach(<VMServiceDiscoveryForAttach>[
mdnsVMServiceDiscoveryForAttach,
super.getVMServiceDiscoveryForAttach(
appId: appId,
fuchsiaModule: fuchsiaModule,
filterDevicePort: filterDevicePort,
expectedHostPort: expectedHostPort,
ipv6: ipv6,
logger: logger,
),
]);
} else {
return mdnsVMServiceDiscoveryForAttach;
}
}
@override
bool get supportsScreenshot {
final Version? xcodeVersion = globals.xcode?.currentVersion;
if (isCoreDevice && xcodeVersion != null && xcodeVersion.major >= 27) {
return globals.xcode!.isDevicectlInstalled;
}
return false;
}
@override
Future<void> takeScreenshot(File outputFile) async {
final Version? xcodeVersion = globals.xcode?.currentVersion;
if (isCoreDevice && xcodeVersion != null && xcodeVersion.major >= 27) {
var success = false;
try {
success = await _coreDeviceControl.takeScreenshot(
deviceId: id,
destination: outputFile.path,
);
} on Exception catch (error) {
final errorMessage = error.toString();
if (errorMessage.contains('CoreDeviceError error 4000') ||
errorMessage.contains('CoreDeviceError error 4016') ||
errorMessage.contains('RemotePairingError error 2') ||
errorMessage.contains('Connection was invalidated')) {
throwToolExit(
'Failed to establish a connection to the device. '
'Please make sure the device is available and try again.',
);
}
throwToolExit('Failed to take screenshot with devicectl: $error');
}
if (success) {
return;
}
throwToolExit('Failed to take screenshot with devicectl.');
}
throwToolExit('flutter screenshot requires Xcode 27 or higher.');
}
@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 kBackslash = 0x5c;
const kM = 0x4d;
const kDash = 0x2d;
const kCaret = 0x5e;
// Mask for the UTF-8 digit range.
const 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 out = <int>[];
for (var 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;
}
}
/// When receiving logs from a device, a [LogInterceptor] can be used to match against a log and
/// perform an [action] if the [pattern] matches.
class LogInterceptor {
LogInterceptor({
required this.identifier,
required this.pattern,
required this.action,
required this.excludeFromStream,
});
/// Unique identifier to make for easy removal from a list.
final String identifier;
/// Logs will be checked to see if they contain the [pattern].
final Pattern pattern;
/// If the log contain the [pattern], the [action] will be called.
final void Function(String message) action;
/// If `true`, the log will be excluded from being added to the stream.
final bool excludeFromStream;
}
/// Shared logic between iOS device log readers, such as [IOSDeviceLogReader]
/// for physical iOS devices and _IOSSimulatorLogReader for simulators.
abstract class SharedIOSDeviceLogReader extends DeviceLogReader {
@visibleForOverriding
/// [StreamController] for iOS device logs.
StreamController<String> get linesController;
/// Interceptors that should be checked with every log.
final List<LogInterceptor> _logInterceptors = [];
void addLogInterceptor(LogInterceptor interceptor) {
_logInterceptors.add(interceptor);
}
/// Once removed, the [LogInterceptor] will no longer intercept logs.
void removeLogInterceptor(LogInterceptor interceptor) {
_logInterceptors.remove(interceptor);
}
/// Remove where [LogInterceptor.identifier] matches [identifier]. Once removed, the
/// [LogInterceptor] will no longer intercept logs.
void removeLogInterceptorByIdentifier(String identifier) {
_logInterceptors.removeWhere((item) => item.identifier == identifier);
}
/// Checks if the [message] matches any [_logInterceptors] and performs the corresponding action
/// of the first matched interceptor.
///
/// Returns `true` if the log should be not added to the [StreamController] to be displayed to the user.
bool _interceptLog(String message) {
for (final LogInterceptor interceptor in _logInterceptors) {
if (message.contains(interceptor.pattern)) {
interceptor.action(message);
if (interceptor.excludeFromStream) {
return true;
}
return false;
}
}
return false;
}
/// Adds [message] to the [linesController] if the [StreamController] is open and the log is not
/// intercepted.
void addLogToStream(String message) {
// 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.
if (!linesController.isClosed && !_interceptLog(message)) {
linesController.add(message);
}
}
}
/// Listens to multiple logging sources to get the logs from the physical iOS device.
///
/// Potential logging sources include:
/// * `idevicesyslog`
/// * `ios-deploy`
/// * `devicectl` and `lldb`
/// * Unified Logging (Dart VM)
///
/// Not all logging sources work on all devices. See [logSources] for limitations.
///
/// Logs are added to the [linesController] and consumed through the [logLines] stream by
/// [FlutterDevice.startEchoingDeviceLog].
class IOSDeviceLogReader extends SharedIOSDeviceLogReader {
IOSDeviceLogReader._(
this._xcode,
this._iMobileDevice,
this._majorSdkVersion,
this._deviceId,
this.name,
this._isWirelesslyConnected,
this._isCoreDevice,
String appName,
bool usingCISystem,
) : // 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;
/// Create a new [IOSDeviceLogReader].
factory IOSDeviceLogReader.create({
required IOSDevice device,
IOSApp? app,
required IMobileDevice iMobileDevice,
required Xcode? xcode,
bool usingCISystem = false,
}) {
final String appName = app?.name?.replaceAll('.app', '') ?? '';
return IOSDeviceLogReader._(
xcode,
iMobileDevice,
device.majorSdkVersion,
device.id,
device.displayName,
device.isWirelesslyConnected,
device.isCoreDevice,
appName,
usingCISystem,
);
}
/// Create an [IOSDeviceLogReader] for testing.
factory IOSDeviceLogReader.test({
required IMobileDevice iMobileDevice,
required Xcode? xcode,
bool useSyslog = true,
bool usingCISystem = false,
int? majorSdkVersion,
bool isWirelesslyConnected = false,
bool isCoreDevice = false,
}) {
final int sdkVersion = majorSdkVersion ?? (useSyslog ? 12 : 13);
return IOSDeviceLogReader._(
xcode,
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 Xcode? _xcode;
final bool _usingCISystem;
// 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 _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 _debuggerLoggingRegex = RegExp(r'^\S* \S* \S*\[[0-9:]*] (.*)');
@override
@visibleForTesting
late final linesController = StreamController<String>.broadcast(
onListen: _listenToSysLog,
onCancel: dispose,
);
@visibleForTesting
void addToLinesController(String message, IOSDeviceLogSource source) {
if (_excludeLog(message, source)) {
return;
}
addLogToStream(message);
}
/// Used to track messages prefixed with "flutter:" from the fallback log source.
final _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 _loggingSubscriptions = <StreamSubscription<void>>[];
@override
Stream<String> get logLines => linesController.stream;
final _coreDeviceLoggingSource = CoreDeviceLoggingSource();
Future<void> listenToCoreDeviceLauncher(IOSCoreDeviceLauncher launcher) async {
if (!useCoreDeviceLogging) {
return;
}
_coreDeviceLoggingSource.coreDeviceLauncher = launcher;
await _coreDeviceLoggingSource.listenToLogs(addToLinesController, linesController);
}
FlutterVmService? _connectedVmService;
@override
Future<void> provideVmService(FlutterVmService connectedVmService) async {
await _listenToUnifiedLoggingEvents(connectedVmService);
_connectedVmService = connectedVmService;
}
static const 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) {
// `idevicesyslog` stopped working with at least Xcode 26 (may have been before).
// Instead, use logging from `devicectl` and `lldb`.
final Version? xcodeVersion = _xcode?.currentVersion;
if (xcodeVersion != null && xcodeVersion.major >= 26) {
return _IOSDeviceLogSources(
primarySource: IOSDeviceLogSource.devicectlAndLldb,
fallbackSource: IOSDeviceLogSource.unifiedLogging,
);
}
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;
}
/// Whether `devicectl` and `lldb` are used as the primary or fallback source for device logs.
@visibleForTesting
bool get useCoreDeviceLogging {
return logSources.primarySource == IOSDeviceLogSource.devicectlAndLldb ||
logSources.fallbackSource == IOSDeviceLogSource.devicectlAndLldb;
}
/// 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 [IOSDeployDebugger.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 [useSyslogLogging] and [useIOSDeployLogging] are `true`.
void _listenToSysLog() {
if (!useSyslogLogging) {
return;
}
_iMobileDevice.startLogger(_deviceId, _isWirelesslyConnected).then<void>((Process process) {
process.stdout.transform(utf8LineDecoder).listen(_newSyslogLineHandler());
process.stderr.transform(utf8LineDecoder).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() {
var 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();
_coreDeviceLoggingSource.dispose();
}
}
enum IOSDeviceLogSource {
/// Gets logs from ios-deploy debugger.
iosDeploy,
/// Gets logs from idevicesyslog.
idevicesyslog,
/// Gets logs from the Dart VM Service.
unifiedLogging,
/// Gets logs from `devicectl` and `lldb`
devicectlAndLldb,
}
class _IOSDeviceLogSources {
_IOSDeviceLogSources({required this.primarySource, this.fallbackSource});
final IOSDeviceLogSource primarySource;
final IOSDeviceLogSource? fallbackSource;
}
@visibleForTesting
class CoreDeviceLoggingSource {
IOSCoreDeviceLauncher? coreDeviceLauncher;
final _loggingSubscriptions = <StreamSubscription<void>>[];
Future<void> listenToLogs(
void Function(String, IOSDeviceLogSource) onLogMessage,
StreamController<String> linesController,
) async {
final IOSCoreDeviceLogForwarder? debugger = coreDeviceLauncher?.coreDeviceLogForwarder;
if (debugger != null) {
_loggingSubscriptions.add(
debugger.logLines.listen(
(String line) =>
onLogMessage(_debuggerLineHandler(line), IOSDeviceLogSource.devicectlAndLldb),
onError: linesController.addError,
onDone: linesController.close,
cancelOnError: true,
),
);
}
final LLDBLogForwarder? lldbLogForwarder = coreDeviceLauncher?.lldbLogForwarder;
if (lldbLogForwarder != null) {
_loggingSubscriptions.add(
lldbLogForwarder.logLines.listen(
(String line) =>
onLogMessage(_debuggerLineHandler(line), IOSDeviceLogSource.devicectlAndLldb),
onError: linesController.addError,
onDone: linesController.close,
cancelOnError: true,
),
);
}
}
// 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 _debuggerLoggingRegex = RegExp(r'^\S* \S* \S*\[[0-9:]*] (.*)');
// Strip off the logging metadata (leave the category), or just echo the line.
String _debuggerLineHandler(String line) =>
_debuggerLoggingRegex.firstMatch(line)?.group(1) ?? line;
void dispose() {
for (final StreamSubscription<void> loggingSubscription in _loggingSubscriptions) {
loggingSubscription.cancel();
}
coreDeviceLauncher?.lldbLogForwarder.exit();
coreDeviceLauncher?.coreDeviceLogForwarder.exit();
}
}
/// A [DevicePortForwarder] specialized for iOS usage with iproxy.
class IOSDevicePortForwarder extends DevicePortForwarder {
/// Create a new [IOSDevicePortForwarder].
IOSDevicePortForwarder({
required this._logger,
required this._id,
required this._iproxy,
required this._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 _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;
var 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.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();
}
}
}