diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart index cb9ab5d585..cda2e4793b 100644 --- a/packages/flutter_tools/lib/src/ios/simulators.dart +++ b/packages/flutter_tools/lib/src/ios/simulators.dart @@ -21,6 +21,9 @@ import 'mac.dart'; const String _xcrunPath = '/usr/bin/xcrun'; +/// Test device created by Flutter when no other device is available. +const String _kFlutterTestDevice = 'flutter.test.device'; + class IOSSimulators extends PollingDeviceDiscovery { IOSSimulators() : super('IOSSimulators'); @@ -49,16 +52,23 @@ class SimControl { /// Returns [SimControl] active in the current app context (i.e. zone). static SimControl get instance => context[SimControl] ?? (context[SimControl] = new SimControl()); - Future boot({String deviceId}) async { + Future boot({String deviceName}) async { if (_isAnyConnected()) return true; - if (deviceId == null) - deviceId = 'iPhone 6 (9.2)'; + if (deviceName == null) { + SimDevice testDevice = _createTestDevice(); + if (testDevice == null) { + return false; + } + deviceName = testDevice.name; + } // `xcrun instruments` requires a template (-t). @yjbanov has no idea what - // "template" is but the built-in 'Blank' seems to work. - List args = [_xcrunPath, 'instruments', '-w', deviceId, '-t', 'Blank']; + // "template" is but the built-in 'Blank' seems to work. -l causes xcrun to + // quit after a time limit without killing the simulator. We quit after + // 1 second. + List args = [_xcrunPath, 'instruments', '-w', deviceName, '-t', 'Blank', '-l', '1']; printTrace(args.join(' ')); runDetached(args); printStatus('Waiting for iOS Simulator to boot...'); @@ -83,9 +93,89 @@ class SimControl { } } - /// Returns a list of all available devices, both potential and connected. - List getDevices() { + SimDevice _createTestDevice() { + String deviceType = _findSuitableDeviceType(); + if (deviceType == null) { + return null; + } + + String runtime = _findSuitableRuntime(); + if (runtime == null) { + return null; + } + + // Delete any old test devices + getDevices() + .where((d) => d.name == _kFlutterTestDevice) + .forEach(_deleteDevice); + + // Create new device + List args = [_xcrunPath, 'simctl', 'create', _kFlutterTestDevice, deviceType, runtime]; + printTrace(args.join(' ')); + runCheckedSync(args); + + return getDevices().firstWhere((d) => d.name == _kFlutterTestDevice); + } + + String _findSuitableDeviceType() { + List> allTypes = _list(SimControlListSection.devicetypes); + List> usableTypes = allTypes + .where((info) => info['name'].startsWith('iPhone')) + .toList() + ..sort((r1, r2) => -compareIphoneVersions(r1['identifier'], r2['identifier'])); + + if (usableTypes.isEmpty) { + printError( + 'No suitable device type found.' + '\n' + 'You may launch an iOS Simulator manually and Flutter will attempt to ' + 'use it.' + ); + } + + return usableTypes.first['identifier']; + } + + String _findSuitableRuntime() { + List> allRuntimes = _list(SimControlListSection.runtimes); + List> usableRuntimes = allRuntimes + .where((info) => info['name'].startsWith('iOS')) + .toList() + ..sort((r1, r2) => -compareIosVersions(r1['version'], r2['version'])); + + if (usableRuntimes.isEmpty) { + printError( + 'No suitable iOS runtime found.' + '\n' + 'You may launch an iOS Simulator manually and Flutter will attempt to ' + 'use it.' + ); + } + + return usableRuntimes.first['identifier']; + } + + void _deleteDevice(SimDevice device) { + try { + List args = [_xcrunPath, 'simctl', 'delete', device.name]; + printTrace(args.join(' ')); + runCheckedSync(args); + } catch(e) { + printError(e); + } + } + + /// Runs `simctl list --json` and returns the JSON of the corresponding + /// [section]. + /// + /// The return type depends on the [section] being listed but is usually + /// either a [Map] or a [List]. + dynamic _list(SimControlListSection section) { + // Sample output from `simctl list --json`: + // // { + // "devicetypes": { ... }, + // "runtimes": { ... }, // "devices" : { // "com.apple.CoreSimulator.SimRuntime.iOS-8-2" : [ // { @@ -95,19 +185,25 @@ class SimControl { // "udid" : "1913014C-6DCB-485D-AC6B-7CD76D322F5B" // }, // ... + // }, + // "pairs": { ... }, - List args = ['simctl', 'list', '--json', 'devices']; + List args = ['simctl', 'list', '--json', section.name]; printTrace('$_xcrunPath ${args.join(' ')}'); ProcessResult results = Process.runSync(_xcrunPath, args); if (results.exitCode != 0) { printError('Error executing simctl: ${results.exitCode}\n${results.stderr}'); - return []; + return >{}; } + return JSON.decode(results.stdout)[section.name]; + } + + /// Returns a list of all available devices, both potential and connected. + List getDevices() { List devices = []; - Map> data = JSON.decode(results.stdout); - Map devicesSection = data['devices']; + Map devicesSection = _list(SimControlListSection.devices); for (String deviceCategory in devicesSection.keys) { List devicesData = devicesSection[deviceCategory]; @@ -192,6 +288,17 @@ class SimControl { } } +/// Enumerates all data sections of `xcrun simctl list --json` command. +class SimControlListSection { + static const devices = const SimControlListSection._('devices'); + static const devicetypes = const SimControlListSection._('devicetypes'); + static const runtimes = const SimControlListSection._('runtimes'); + static const pairs = const SimControlListSection._('pairs'); + + final String name; + const SimControlListSection._(this.name); +} + class SimDevice { SimDevice(this.category, this.data); @@ -621,3 +728,47 @@ class _IOSSimulatorLogReader extends DeviceLogReader { return other.device.logFilePath == device.logFilePath; } } + +int compareIosVersions(String v1, String v2) { + List v1Fragments = v1.split('.').map(int.parse).toList(); + List v2Fragments = v2.split('.').map(int.parse).toList(); + + int i = 0; + while(i < v1Fragments.length && i < v2Fragments.length) { + int v1Fragment = v1Fragments[i]; + int v2Fragment = v2Fragments[i]; + if (v1Fragment != v2Fragment) + return v1Fragment.compareTo(v2Fragment); + i++; + } + return v1Fragments.length.compareTo(v2Fragments.length); +} + +/// Matches on device type given an identifier. +/// +/// Example device type identifiers: +/// ✓ com.apple.CoreSimulator.SimDeviceType.iPhone-5 +/// ✓ com.apple.CoreSimulator.SimDeviceType.iPhone-6 +/// ✓ com.apple.CoreSimulator.SimDeviceType.iPhone-6s-Plus +/// ✗ com.apple.CoreSimulator.SimDeviceType.iPad-2 +/// ✗ com.apple.CoreSimulator.SimDeviceType.Apple-Watch-38mm +final RegExp _iosDeviceTypePattern = + new RegExp(r'com.apple.CoreSimulator.SimDeviceType.iPhone-(\d+)(.*)'); + +int compareIphoneVersions(String id1, String id2) { + Match m1 = _iosDeviceTypePattern.firstMatch(id1); + Match m2 = _iosDeviceTypePattern.firstMatch(id2); + + int v1 = int.parse(m1[1]); + int v2 = int.parse(m2[1]); + + if (v1 != v2) + return v1.compareTo(v2); + + // Sorted in the least preferred first order. + const qualifiers = const ['-Plus', '', 's-Plus', 's']; + + int q1 = qualifiers.indexOf(m1[2]); + int q2 = qualifiers.indexOf(m2[2]); + return q1.compareTo(q2); +} diff --git a/packages/flutter_tools/test/src/ios/simulators_test.dart b/packages/flutter_tools/test/src/ios/simulators_test.dart new file mode 100644 index 0000000000..38bc1081be --- /dev/null +++ b/packages/flutter_tools/test/src/ios/simulators_test.dart @@ -0,0 +1,54 @@ +import 'package:test/test.dart'; + +import 'package:flutter_tools/src/ios/simulators.dart'; + +main() { + group('compareIosVersions', () { + test('compares correctly', () { + // This list must be sorted in ascending preference order + List testList = [ + '8', '8.0', '8.1', '8.2', + '9', '9.0', '9.1', '9.2', + '10', '10.0', '10.1', + ]; + + for (int i = 0; i < testList.length; i++) { + expect(compareIosVersions(testList[i], testList[i]), 0); + } + + for (int i = 0; i < testList.length - 1; i++) { + for (int j = i + 1; j < testList.length; j++) { + expect(compareIosVersions(testList[i], testList[j]), lessThan(0)); + expect(compareIosVersions(testList[j], testList[i]), greaterThan(0)); + } + } + }); + }); + + group('compareIphoneVersions', () { + test('compares correctly', () { + // This list must be sorted in ascending preference order + List testList = [ + 'com.apple.CoreSimulator.SimDeviceType.iPhone-4s', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-5', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-5s', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-6strange', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-6-Plus', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-6', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-6s-Plus', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-6s', + ]; + + for (int i = 0; i < testList.length; i++) { + expect(compareIphoneVersions(testList[i], testList[i]), 0); + } + + for (int i = 0; i < testList.length - 1; i++) { + for (int j = i + 1; j < testList.length; j++) { + expect(compareIphoneVersions(testList[i], testList[j]), lessThan(0)); + expect(compareIphoneVersions(testList[j], testList[i]), greaterThan(0)); + } + } + }); + }); +}