blob: 73dccc173f54818b7a195928945918141d8a8a50 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:meta/meta.dart';
import 'package:process/process.dart';
import '../artifacts.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../base/version.dart';
import '../build_info.dart';
import '../cache.dart';
import '../convert.dart';
import '../globals.dart' as globals;
import '../ios/devices.dart';
import '../ios/ios_deploy.dart';
import '../ios/iproxy.dart';
import '../ios/mac.dart';
import '../ios/xcodeproj.dart';
import '../reporting/reporting.dart';
Version get xcodeRequiredVersion => Version(11, 0, 0, text: '11.0');
Version get xcodeRecommendedVersion => Version(12, 0, 1, text: '12.0.1');
/// SDK name passed to `xcrun --sdk`. Corresponds to undocumented Xcode
/// SUPPORTED_PLATFORMS values.
///
/// Usage: xcrun [options] <tool name> ... arguments ...
/// ...
/// --sdk <sdk name> find the tool for the given SDK name.
String getSDKNameForIOSEnvironmentType(EnvironmentType environmentType) {
return (environmentType == EnvironmentType.simulator)
? 'iphonesimulator'
: 'iphoneos';
}
/// A utility class for interacting with Xcode command line tools.
class Xcode {
Xcode({
@required Platform platform,
@required ProcessManager processManager,
@required Logger logger,
@required FileSystem fileSystem,
@required XcodeProjectInterpreter xcodeProjectInterpreter,
}) : _platform = platform,
_fileSystem = fileSystem,
_xcodeProjectInterpreter = xcodeProjectInterpreter,
_processUtils =
ProcessUtils(logger: logger, processManager: processManager);
final Platform _platform;
final ProcessUtils _processUtils;
final FileSystem _fileSystem;
final XcodeProjectInterpreter _xcodeProjectInterpreter;
bool get isInstalledAndMeetsVersionCheck => _platform.isMacOS && isInstalled && isRequiredVersionSatisfactory;
String _xcodeSelectPath;
String get xcodeSelectPath {
if (_xcodeSelectPath == null) {
try {
_xcodeSelectPath = _processUtils.runSync(
<String>['/usr/bin/xcode-select', '--print-path'],
).stdout.trim();
} on ProcessException {
// Ignored, return null below.
} on ArgumentError {
// Ignored, return null below.
}
}
return _xcodeSelectPath;
}
bool get isInstalled {
if (xcodeSelectPath == null || xcodeSelectPath.isEmpty) {
return false;
}
return _xcodeProjectInterpreter.isInstalled;
}
Version get currentVersion => Version(
_xcodeProjectInterpreter.majorVersion,
_xcodeProjectInterpreter.minorVersion,
_xcodeProjectInterpreter.patchVersion,
text:
'${_xcodeProjectInterpreter.majorVersion}.${_xcodeProjectInterpreter.minorVersion}.${_xcodeProjectInterpreter.patchVersion}',
);
String get versionText => _xcodeProjectInterpreter.versionText;
bool _eulaSigned;
/// Has the EULA been signed?
bool get eulaSigned {
if (_eulaSigned == null) {
try {
final RunResult result = _processUtils.runSync(
<String>[...xcrunCommand(), 'clang'],
);
if (result.stdout != null && result.stdout.contains('license')) {
_eulaSigned = false;
} else if (result.stderr != null && result.stderr.contains('license')) {
_eulaSigned = false;
} else {
_eulaSigned = true;
}
} on ProcessException {
_eulaSigned = false;
}
}
return _eulaSigned;
}
bool _isSimctlInstalled;
/// Verifies that simctl is installed by trying to run it.
bool get isSimctlInstalled {
if (_isSimctlInstalled == null) {
try {
// This command will error if additional components need to be installed in
// xcode 9.2 and above.
final RunResult result = _processUtils.runSync(
<String>[...xcrunCommand(), 'simctl', 'list'],
);
_isSimctlInstalled = result.stderr == null || result.stderr == '';
} on ProcessException {
_isSimctlInstalled = false;
}
}
return _isSimctlInstalled;
}
bool get isRequiredVersionSatisfactory {
if (!_xcodeProjectInterpreter.isInstalled) {
return false;
}
return currentVersion >= xcodeRequiredVersion;
}
bool get isRecommendedVersionSatisfactory {
if (!_xcodeProjectInterpreter.isInstalled) {
return false;
}
return currentVersion >= xcodeRecommendedVersion;
}
/// See [XcodeProjectInterpreter.xcrunCommand].
List<String> xcrunCommand() => _xcodeProjectInterpreter.xcrunCommand();
Future<RunResult> cc(List<String> args) {
return _processUtils.run(
<String>[...xcrunCommand(), 'cc', ...args],
throwOnError: true,
);
}
Future<RunResult> clang(List<String> args) {
return _processUtils.run(
<String>[...xcrunCommand(), 'clang', ...args],
throwOnError: true,
);
}
Future<String> sdkLocation(EnvironmentType environmentType) async {
assert(environmentType != null);
final RunResult runResult = await _processUtils.run(
<String>[...xcrunCommand(), '--sdk', getSDKNameForIOSEnvironmentType(environmentType), '--show-sdk-path'],
);
if (runResult.exitCode != 0) {
throwToolExit('Could not find SDK location: ${runResult.stderr}');
}
return runResult.stdout.trim();
}
String getSimulatorPath() {
if (xcodeSelectPath == null) {
return null;
}
final List<String> searchPaths = <String>[
_fileSystem.path.join(xcodeSelectPath, 'Applications', 'Simulator.app'),
];
return searchPaths.where((String p) => p != null).firstWhere(
(String p) => _fileSystem.directory(p).existsSync(),
orElse: () => null,
);
}
}
EnvironmentType environmentTypeFromSdkroot(Directory sdkroot) {
assert(sdkroot != null);
// iPhoneSimulator.sdk or iPhoneOS.sdk
final String sdkName = sdkroot.basename.toLowerCase();
if (sdkName.contains('iphone')) {
return sdkName.contains('simulator') ? EnvironmentType.simulator : EnvironmentType.physical;
}
assert(false);
return null;
}
enum XCDeviceEvent {
attach,
detach,
}
/// A utility class for interacting with Xcode xcdevice command line tools.
class XCDevice {
XCDevice({
@required Artifacts artifacts,
@required Cache cache,
@required ProcessManager processManager,
@required Logger logger,
@required Xcode xcode,
@required Platform platform,
@required IProxy iproxy,
}) : _processUtils = ProcessUtils(logger: logger, processManager: processManager),
_logger = logger,
_iMobileDevice = IMobileDevice(
artifacts: artifacts,
cache: cache,
logger: logger,
processManager: processManager,
),
_iosDeploy = IOSDeploy(
artifacts: artifacts,
cache: cache,
logger: logger,
platform: platform,
processManager: processManager,
),
_iProxy = iproxy,
_xcode = xcode {
_setupDeviceIdentifierByEventStream();
}
void dispose() {
_deviceObservationProcess?.kill();
}
final ProcessUtils _processUtils;
final Logger _logger;
final IMobileDevice _iMobileDevice;
final IOSDeploy _iosDeploy;
final Xcode _xcode;
final IProxy _iProxy;
List<dynamic> _cachedListResults;
Process _deviceObservationProcess;
StreamController<Map<XCDeviceEvent, String>> _deviceIdentifierByEvent;
void _setupDeviceIdentifierByEventStream() {
// _deviceIdentifierByEvent Should always be available for listeners
// in case polling needs to be stopped and restarted.
_deviceIdentifierByEvent = StreamController<Map<XCDeviceEvent, String>>.broadcast(
onListen: _startObservingTetheredIOSDevices,
onCancel: _stopObservingTetheredIOSDevices,
);
}
bool get isInstalled => _xcode.isInstalledAndMeetsVersionCheck;
Future<List<dynamic>> _getAllDevices({
bool useCache = false,
@required Duration timeout
}) async {
if (!isInstalled) {
_logger.printTrace("Xcode not found. Run 'flutter doctor' for more information.");
return null;
}
if (useCache && _cachedListResults != null) {
return _cachedListResults;
}
try {
// USB-tethered devices should be found quickly. 1 second timeout is faster than the default.
final RunResult result = await _processUtils.run(
<String>[
..._xcode.xcrunCommand(),
'xcdevice',
'list',
'--timeout',
timeout.inSeconds.toString(),
],
throwOnError: true,
);
if (result.exitCode == 0) {
final List<dynamic> listResults = json.decode(result.stdout) as List<dynamic>;
_cachedListResults = listResults;
return listResults;
}
_logger.printTrace('xcdevice returned an error:\n${result.stderr}');
} on ProcessException catch (exception) {
_logger.printTrace('Process exception running xcdevice list:\n$exception');
} on ArgumentError catch (exception) {
_logger.printTrace('Argument exception running xcdevice list:\n$exception');
}
return null;
}
/// Observe identifiers (UDIDs) of devices as they attach and detach.
///
/// Each attach and detach event is a tuple of one event type
/// and identifier.
Stream<Map<XCDeviceEvent, String>> observedDeviceEvents() {
if (!isInstalled) {
_logger.printTrace("Xcode not found. Run 'flutter doctor' for more information.");
return null;
}
return _deviceIdentifierByEvent.stream;
}
// Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
// Attach: 00008027-00192736010F802E
// Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
final RegExp _observationIdentifierPattern = RegExp(r'^(\w*): ([\w-]*)$');
Future<void> _startObservingTetheredIOSDevices() async {
try {
if (_deviceObservationProcess != null) {
throw Exception('xcdevice observe restart failed');
}
// Run in interactive mode (via script) to convince
// xcdevice it has a terminal attached in order to redirect stdout.
_deviceObservationProcess = await _processUtils.start(
<String>[
'script',
'-t',
'0',
'/dev/null',
..._xcode.xcrunCommand(),
'xcdevice',
'observe',
'--both',
],
);
final StreamSubscription<String> stdoutSubscription = _deviceObservationProcess.stdout
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.listen((String line) {
// xcdevice observe example output of UDIDs:
//
// Listening for all devices, on both interfaces.
// Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
// Attach: 00008027-00192736010F802E
// Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
// Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
final RegExpMatch match = _observationIdentifierPattern.firstMatch(line);
if (match != null && match.groupCount == 2) {
final String verb = match.group(1).toLowerCase();
final String identifier = match.group(2);
if (verb.startsWith('attach')) {
_deviceIdentifierByEvent.add(<XCDeviceEvent, String>{
XCDeviceEvent.attach: identifier
});
} else if (verb.startsWith('detach')) {
_deviceIdentifierByEvent.add(<XCDeviceEvent, String>{
XCDeviceEvent.detach: identifier
});
}
}
});
final StreamSubscription<String> stderrSubscription = _deviceObservationProcess.stderr
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.listen((String line) {
_logger.printTrace('xcdevice observe error: $line');
});
unawaited(_deviceObservationProcess.exitCode.then((int status) {
_logger.printTrace('xcdevice exited with code $exitCode');
unawaited(stdoutSubscription.cancel());
unawaited(stderrSubscription.cancel());
}).whenComplete(() async {
if (_deviceIdentifierByEvent.hasListener) {
// Tell listeners the process died.
await _deviceIdentifierByEvent.close();
}
_deviceObservationProcess = null;
// Reopen it so new listeners can resume polling.
_setupDeviceIdentifierByEventStream();
}));
} on ProcessException catch (exception, stackTrace) {
_deviceIdentifierByEvent.addError(exception, stackTrace);
} on ArgumentError catch (exception, stackTrace) {
_deviceIdentifierByEvent.addError(exception, stackTrace);
}
}
void _stopObservingTetheredIOSDevices() {
_deviceObservationProcess?.kill();
}
/// [timeout] defaults to 2 seconds.
Future<List<IOSDevice>> getAvailableIOSDevices({ Duration timeout }) async {
final List<dynamic> allAvailableDevices = await _getAllDevices(timeout: timeout ?? const Duration(seconds: 2));
if (allAvailableDevices == null) {
return const <IOSDevice>[];
}
// [
// {
// "simulator" : true,
// "operatingSystemVersion" : "13.3 (17K446)",
// "available" : true,
// "platform" : "com.apple.platform.appletvsimulator",
// "modelCode" : "AppleTV5,3",
// "identifier" : "CBB5E1ED-2172-446E-B4E7-F2B5823DBBA6",
// "architecture" : "x86_64",
// "modelName" : "Apple TV",
// "name" : "Apple TV"
// },
// {
// "simulator" : false,
// "operatingSystemVersion" : "13.3 (17C54)",
// "interface" : "usb",
// "available" : true,
// "platform" : "com.apple.platform.iphoneos",
// "modelCode" : "iPhone8,1",
// "identifier" : "d83d5bc53967baa0ee18626ba87b6254b2ab5418",
// "architecture" : "arm64",
// "modelName" : "iPhone 6s",
// "name" : "iPhone"
// },
// {
// "simulator" : true,
// "operatingSystemVersion" : "6.1.1 (17S445)",
// "available" : true,
// "platform" : "com.apple.platform.watchsimulator",
// "modelCode" : "Watch5,4",
// "identifier" : "2D74FB11-88A0-44D0-B81E-C0C142B1C94A",
// "architecture" : "i386",
// "modelName" : "Apple Watch Series 5 - 44mm",
// "name" : "Apple Watch Series 5 - 44mm"
// },
// ...
final List<IOSDevice> devices = <IOSDevice>[];
for (final dynamic device in allAvailableDevices) {
if (device is! Map) {
continue;
}
final Map<String, dynamic> deviceProperties = device as Map<String, dynamic>;
// Only include iPhone, iPad, iPod, or other iOS devices.
if (!_isIPhoneOSDevice(deviceProperties)) {
continue;
}
final Map<String, dynamic> errorProperties = _errorProperties(deviceProperties);
if (errorProperties != null) {
final String errorMessage = _parseErrorMessage(errorProperties);
if (errorMessage.contains('not paired')) {
UsageEvent('device', 'ios-trust-failure', flutterUsage: globals.flutterUsage).send();
}
_logger.printTrace(errorMessage);
final int code = _errorCode(errorProperties);
// Temporary error -10: iPhone is busy: Preparing debugger support for iPhone.
// Sometimes the app launch will fail on these devices until Xcode is done setting up the device.
// Other times this is a false positive and the app will successfully launch despite the error.
if (code != -10) {
continue;
}
}
final IOSDeviceInterface interface = _interfaceType(deviceProperties);
// Only support USB devices, skip "network" interface (Xcode > Window > Devices and Simulators > Connect via network).
// TODO(jmagman): Remove this check once wirelessly detected devices can be observed and attached, https://github.com/flutter/flutter/issues/15072.
if (interface != IOSDeviceInterface.usb) {
continue;
}
devices.add(IOSDevice(
device['identifier'] as String,
name: device['name'] as String,
cpuArchitecture: _cpuArchitecture(deviceProperties),
interfaceType: interface,
sdkVersion: _sdkVersion(deviceProperties),
iProxy: _iProxy,
fileSystem: globals.fs,
logger: _logger,
iosDeploy: _iosDeploy,
iMobileDevice: _iMobileDevice,
platform: globals.platform,
));
}
return devices;
}
/// Despite the name, com.apple.platform.iphoneos includes iPhone, iPads, and all iOS devices.
/// Excludes simulators.
static bool _isIPhoneOSDevice(Map<String, dynamic> deviceProperties) {
if (deviceProperties.containsKey('platform')) {
final String platform = deviceProperties['platform'] as String;
return platform == 'com.apple.platform.iphoneos';
}
return false;
}
static Map<String, dynamic> _errorProperties(Map<String, dynamic> deviceProperties) {
if (deviceProperties.containsKey('error')) {
return deviceProperties['error'] as Map<String, dynamic>;
}
return null;
}
static int _errorCode(Map<String, dynamic> errorProperties) {
if (errorProperties.containsKey('code') && errorProperties['code'] is int) {
return errorProperties['code'] as int;
}
return null;
}
static IOSDeviceInterface _interfaceType(Map<String, dynamic> deviceProperties) {
// Interface can be "usb", "network", or "none" for simulators
// and unknown future interfaces.
if (deviceProperties.containsKey('interface')) {
if ((deviceProperties['interface'] as String).toLowerCase() == 'network') {
return IOSDeviceInterface.network;
} else {
return IOSDeviceInterface.usb;
}
}
return IOSDeviceInterface.none;
}
static String _sdkVersion(Map<String, dynamic> deviceProperties) {
if (deviceProperties.containsKey('operatingSystemVersion')) {
// Parse out the OS version, ignore the build number in parentheses.
// "13.3 (17C54)"
final RegExp operatingSystemRegex = RegExp(r'(.*) \(.*\)$');
final String operatingSystemVersion = deviceProperties['operatingSystemVersion'] as String;
return operatingSystemRegex.firstMatch(operatingSystemVersion.trim())?.group(1);
}
return null;
}
DarwinArch _cpuArchitecture(Map<String, dynamic> deviceProperties) {
DarwinArch cpuArchitecture;
if (deviceProperties.containsKey('architecture')) {
final String architecture = deviceProperties['architecture'] as String;
try {
cpuArchitecture = getIOSArchForName(architecture);
} on Exception {
// Fallback to default iOS architecture. Future-proof against a
// theoretical version of Xcode that changes this string to something
// slightly different like "ARM64", or armv7 variations like
// armv7s and armv7f.
if (architecture.startsWith('armv7')) {
cpuArchitecture = DarwinArch.armv7;
} else {
cpuArchitecture = DarwinArch.arm64;
}
_logger.printError(
'Unknown architecture $architecture, defaulting to '
'${getNameForDarwinArch(cpuArchitecture)}',
);
}
}
return cpuArchitecture;
}
/// Error message parsed from xcdevice. null if no error.
static String _parseErrorMessage(Map<String, dynamic> errorProperties) {
// {
// "simulator" : false,
// "operatingSystemVersion" : "13.3 (17C54)",
// "interface" : "usb",
// "available" : false,
// "platform" : "com.apple.platform.iphoneos",
// "modelCode" : "iPhone8,1",
// "identifier" : "98206e7a4afd4aedaff06e687594e089dede3c44",
// "architecture" : "arm64",
// "modelName" : "iPhone 6s",
// "name" : "iPhone",
// "error" : {
// "code" : -9,
// "failureReason" : "",
// "underlyingErrors" : [
// {
// "code" : 5,
// "failureReason" : "allowsSecureServices: 1. isConnected: 0. Platform: <DVTPlatform:0x7f804ce32880:'com.apple.platform.iphoneos':<DVTFilePath:0x7f804ce32800:'\/Users\/magder\/Applications\/Xcode_11-3-1.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform'>>. DTDKDeviceIdentifierIsIDID: 0",
// "description" : "📱<DVTiOSDevice (0x7f801f190450), iPhone, iPhone, 13.3 (17C54), d83d5bc53967baa0ee18626ba87b6254b2ab5418> -- Failed _shouldMakeReadyForDevelopment check even though device is not locked by passcode.",
// "recoverySuggestion" : "",
// "domain" : "com.apple.platform.iphoneos"
// }
// ],
// "description" : "iPhone is not paired with your computer.",
// "recoverySuggestion" : "To use iPhone with Xcode, unlock it and choose to trust this computer when prompted.",
// "domain" : "com.apple.platform.iphoneos"
// }
// },
// {
// "simulator" : false,
// "operatingSystemVersion" : "13.3 (17C54)",
// "interface" : "usb",
// "available" : false,
// "platform" : "com.apple.platform.iphoneos",
// "modelCode" : "iPhone8,1",
// "identifier" : "d83d5bc53967baa0ee18626ba87b6254b2ab5418",
// "architecture" : "arm64",
// "modelName" : "iPhone 6s",
// "name" : "iPhone",
// "error" : {
// "code" : -9,
// "failureReason" : "",
// "description" : "iPhone is not paired with your computer.",
// "domain" : "com.apple.platform.iphoneos"
// }
// }
// ...
if (errorProperties == null) {
return null;
}
final StringBuffer errorMessage = StringBuffer('Error: ');
if (errorProperties.containsKey('description')) {
final String description = errorProperties['description'] as String;
errorMessage.write(description);
if (!description.endsWith('.')) {
errorMessage.write('.');
}
} else {
errorMessage.write('Xcode pairing error.');
}
if (errorProperties.containsKey('recoverySuggestion')) {
final String recoverySuggestion = errorProperties['recoverySuggestion'] as String;
errorMessage.write(' $recoverySuggestion');
}
final int code = _errorCode(errorProperties);
if (code != null) {
errorMessage.write(' (code $code)');
}
return errorMessage.toString();
}
/// List of all devices reporting errors.
Future<List<String>> getDiagnostics() async {
final List<dynamic> allAvailableDevices = await _getAllDevices(
useCache: true,
timeout: const Duration(seconds: 2)
);
if (allAvailableDevices == null) {
return const <String>[];
}
final List<String> diagnostics = <String>[];
for (final dynamic device in allAvailableDevices) {
if (device is! Map) {
continue;
}
final Map<String, dynamic> deviceProperties = device as Map<String, dynamic>;
final Map<String, dynamic> errorProperties = _errorProperties(deviceProperties);
final String errorMessage = _parseErrorMessage(errorProperties);
if (errorMessage != null) {
diagnostics.add(errorMessage);
}
}
return diagnostics;
}
}