
This pull request fixes #143803 by taking advantage of Dart's null-aware operators. And unlike `switch` expressions ([9 PRs](https://github.com/flutter/flutter/pull/143634) and counting), the Flutter codebase is already fantastic when it comes to null-aware coding. After refactoring the entire repo, all the changes involving `?.` and `??` can fit into a single pull request.
1642 lines
56 KiB
Dart
1642 lines
56 KiB
Dart
// 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
|
|
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) {
|
|
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,
|
|
disablePortPublication: debuggingOptions.usingCISystem && debuggingOptions.disablePortPublication,
|
|
);
|
|
if (!buildResult.success) {
|
|
_logger.printError('Could not build the precompiled application for the device.');
|
|
await diagnoseXcodeBuildFailure(buildResult, globals.flutterUsage, _logger, globals.analytics);
|
|
_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');
|
|
vmServiceDiscovery = _setupDebuggerAndVmServiceDiscovery(
|
|
package: package,
|
|
bundle: bundle,
|
|
debuggingOptions: debuggingOptions,
|
|
launchArguments: launchArguments,
|
|
ipv6: ipv6,
|
|
uninstallFirst: debuggingOptions.uninstallFirst,
|
|
);
|
|
}
|
|
|
|
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) {
|
|
_printInstallError(bundle);
|
|
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 (isCoreDevice || forceXcodeDebugWorkflow) {
|
|
localUri = await _discoverDartVMForCoreDevice(
|
|
debuggingOptions: debuggingOptions,
|
|
packageId: packageId,
|
|
ipv6: ipv6,
|
|
vmServiceDiscovery: vmServiceDiscovery,
|
|
);
|
|
} 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();
|
|
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 {
|
|
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,
|
|
ipv6: ipv6,
|
|
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();
|
|
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();
|
|
|
|
if ((isCoreDevice || forceXcodeDebugWorkflow) && 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,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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('');
|
|
}
|
|
|
|
/// 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 bool ipv6,
|
|
required DebuggingOptions debuggingOptions,
|
|
ProtocolDiscovery? vmServiceDiscovery,
|
|
}) async {
|
|
Timer? maxWaitForCI;
|
|
final Completer<Uri?> cancelCompleter = Completer<Uri?>();
|
|
|
|
// When testing in CI, wait a max of 10 minutes for the Dart VM to be found.
|
|
// Afterwards, stop the app from running and upload DerivedData Logs to debug
|
|
// logs directory. CoreDevices are run through Xcode and launch logs are
|
|
// therefore found in DerivedData.
|
|
if (debuggingOptions.usingCISystem && debuggingOptions.debugLogsDirectoryPath != null) {
|
|
maxWaitForCI = Timer(const Duration(minutes: 10), () async {
|
|
_logger.printError('Failed to find Dart VM after 10 minutes.');
|
|
await _xcodeDebug.exit();
|
|
final String? homePath = _platform.environment['HOME'];
|
|
Directory? derivedData;
|
|
if (homePath != null) {
|
|
derivedData = _fileSystem.directory(
|
|
_fileSystem.path.join(homePath, 'Library', 'Developer', 'Xcode', 'DerivedData'),
|
|
);
|
|
}
|
|
if (derivedData != null && derivedData.existsSync()) {
|
|
final Directory debugLogsDirectory = _fileSystem.directory(
|
|
debuggingOptions.debugLogsDirectoryPath,
|
|
);
|
|
debugLogsDirectory.createSync(recursive: true);
|
|
for (final FileSystemEntity entity in derivedData.listSync()) {
|
|
if (entity is! Directory || !entity.childDirectory('Logs').existsSync()) {
|
|
continue;
|
|
}
|
|
final Directory logsToCopy = entity.childDirectory('Logs');
|
|
final Directory copyDestination = debugLogsDirectory
|
|
.childDirectory('DerivedDataLogs')
|
|
.childDirectory(entity.basename)
|
|
.childDirectory('Logs');
|
|
_logger.printTrace('Copying logs ${logsToCopy.path} to ${copyDestination.path}...');
|
|
copyDirectory(logsToCopy, copyDestination);
|
|
}
|
|
}
|
|
cancelCompleter.complete();
|
|
});
|
|
}
|
|
|
|
final Future<Uri?> vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch(
|
|
packageId,
|
|
this,
|
|
usesIpv6: ipv6,
|
|
useDeviceIPAsHost: isWirelesslyConnected,
|
|
);
|
|
|
|
final List<Future<Uri?>> discoveryOptions = <Future<Uri?>>[
|
|
vmUrlFromMDns,
|
|
];
|
|
|
|
// vmServiceDiscovery uses device logs (`idevicesyslog`), which doesn't work
|
|
// on wireless devices.
|
|
if (vmServiceDiscovery != null && !isWirelesslyConnected) {
|
|
final Future<Uri?> vmUrlFromLogs = vmServiceDiscovery.uri;
|
|
discoveryOptions.add(vmUrlFromLogs);
|
|
}
|
|
|
|
Uri? localUri = await Future.any(
|
|
<Future<Uri?>>[...discoveryOptions, cancelCompleter.future],
|
|
);
|
|
|
|
// If the first future to return is null, wait for the other to complete
|
|
// unless canceled.
|
|
if (localUri == null && !cancelCompleter.isCompleted) {
|
|
final Future<List<Uri?>> allDiscoveryOptionsComplete = Future.wait(discoveryOptions);
|
|
await Future.any(<Future<Object?>>[
|
|
allDiscoveryOptionsComplete,
|
|
cancelCompleter.future,
|
|
]);
|
|
if (!cancelCompleter.isCompleted) {
|
|
// If it wasn't cancelled, that means one of the discovery options completed.
|
|
final List<Uri?> vmUrls = await allDiscoveryOptionsComplete;
|
|
localUri = vmUrls.where((Uri? vmUrl) => vmUrl != null).firstOrNull;
|
|
}
|
|
}
|
|
maxWaitForCI?.cancel();
|
|
return localUri;
|
|
}
|
|
|
|
ProtocolDiscovery _setupDebuggerAndVmServiceDiscovery({
|
|
required IOSApp package,
|
|
required Directory bundle,
|
|
required DebuggingOptions debuggingOptions,
|
|
required List<String> launchArguments,
|
|
required bool ipv6,
|
|
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: ipv6,
|
|
logger: _logger,
|
|
);
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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 = majorSdkVersion ?? (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, _isWirelesslyConnected).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();
|
|
}
|
|
}
|
|
}
|