// Copyright 2016 The Chromium 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 'dart:convert' show JSON; import 'dart:io'; import 'package:path/path.dart' as path; import '../application_package.dart'; import '../base/common.dart'; import '../base/context.dart'; import '../base/process.dart'; import '../build_configuration.dart'; import '../device.dart'; import '../flx.dart' as flx; import '../globals.dart'; import '../toolchain.dart'; import 'mac.dart'; const String _xcrunPath = '/usr/bin/xcrun'; const String _simulatorPath = '/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/Contents/MacOS/Simulator'; class IOSSimulators extends PollingDeviceDiscovery { IOSSimulators() : super('IOSSimulators'); bool get supportsPlatform => Platform.isMacOS; List<Device> pollingGetDevices() => IOSSimulatorUtils.instance.getAttachedDevices(); } class IOSSimulatorUtils { /// Returns [IOSSimulatorUtils] active in the current app context (i.e. zone). static IOSSimulatorUtils get instance { return context[IOSSimulatorUtils] ?? (context[IOSSimulatorUtils] = new IOSSimulatorUtils()); } List<IOSSimulator> getAttachedDevices() { if (!XCode.instance.isInstalledAndMeetsVersionCheck) return <IOSSimulator>[]; return SimControl.instance.getConnectedDevices().map((SimDevice device) { return new IOSSimulator(device.udid, name: device.name); }).toList(); } } /// A wrapper around the `simctl` command line tool. class SimControl { /// Returns [SimControl] active in the current app context (i.e. zone). static SimControl get instance => context[SimControl] ?? (context[SimControl] = new SimControl()); Future<bool> boot({String deviceId}) async { if (_isAnyConnected()) return true; if (deviceId == null) { runDetached([_simulatorPath]); Future<bool> checkConnection([int attempts = 20]) async { if (attempts == 0) { printStatus('Timed out waiting for iOS Simulator to boot.'); return false; } if (!_isAnyConnected()) { printStatus('Waiting for iOS Simulator to boot...'); return await new Future.delayed(new Duration(milliseconds: 500), () => checkConnection(attempts - 1) ); } return true; } return await checkConnection(); } else { try { runCheckedSync([_xcrunPath, 'simctl', 'boot', deviceId]); return true; } catch (e) { printError('Unable to boot iOS Simulator $deviceId: ', e); return false; } } return false; } /// Returns a list of all available devices, both potential and connected. List<SimDevice> getDevices() { // { // "devices" : { // "com.apple.CoreSimulator.SimRuntime.iOS-8-2" : [ // { // "state" : "Shutdown", // "availability" : " (unavailable, runtime profile not found)", // "name" : "iPhone 4s", // "udid" : "1913014C-6DCB-485D-AC6B-7CD76D322F5B" // }, // ... List<String> args = <String>['simctl', 'list', '--json', 'devices']; printTrace('$_xcrunPath ${args.join(' ')}'); ProcessResult results = Process.runSync(_xcrunPath, args); if (results.exitCode != 0) { printError('Error executing simctl: ${results.exitCode}\n${results.stderr}'); return <SimDevice>[]; } List<SimDevice> devices = <SimDevice>[]; Map<String, Map<String, dynamic>> data = JSON.decode(results.stdout); Map<String, dynamic> devicesSection = data['devices']; for (String deviceCategory in devicesSection.keys) { List<dynamic> devicesData = devicesSection[deviceCategory]; for (Map<String, String> data in devicesData) { devices.add(new SimDevice(deviceCategory, data)); } } return devices; } /// Returns all the connected simulator devices. List<SimDevice> getConnectedDevices() { return getDevices().where((SimDevice device) => device.isBooted).toList(); } StreamController<List<SimDevice>> _trackDevicesControler; /// Listens to changes in the set of connected devices. The implementation /// currently uses polling. Callers should be careful to call cancel() on any /// stream subscription when finished. /// /// TODO(devoncarew): We could investigate using the usbmuxd protocol directly. Stream<List<SimDevice>> trackDevices() { if (_trackDevicesControler == null) { Timer timer; Set<String> deviceIds = new Set<String>(); _trackDevicesControler = new StreamController.broadcast( onListen: () { timer = new Timer.periodic(new Duration(seconds: 4), (Timer timer) { List<SimDevice> devices = getConnectedDevices(); if (_updateDeviceIds(devices, deviceIds)) { _trackDevicesControler.add(devices); } }); }, onCancel: () { timer?.cancel(); deviceIds.clear(); } ); } return _trackDevicesControler.stream; } /// Update the cached set of device IDs and return whether there were any changes. bool _updateDeviceIds(List<SimDevice> devices, Set<String> deviceIds) { Set<String> newIds = new Set<String>.from(devices.map((SimDevice device) => device.udid)); bool changed = false; for (String id in newIds) { if (!deviceIds.contains(id)) changed = true; } for (String id in deviceIds) { if (!newIds.contains(id)) changed = true; } deviceIds.clear(); deviceIds.addAll(newIds); return changed; } bool _isAnyConnected() => getConnectedDevices().isNotEmpty; void install(String deviceId, String appPath) { runCheckedSync([_xcrunPath, 'simctl', 'install', deviceId, appPath]); } void launch(String deviceId, String appIdentifier, [List<String> launchArgs]) { List<String> args = [_xcrunPath, 'simctl', 'launch', deviceId, appIdentifier]; if (launchArgs != null) args.addAll(launchArgs); runCheckedSync(args); } } class SimDevice { SimDevice(this.category, this.data); final String category; final Map<String, String> data; String get state => data['state']; String get availability => data['availability']; String get name => data['name']; String get udid => data['udid']; bool get isBooted => state == 'Booted'; } class IOSSimulator extends Device { IOSSimulator(String id, { this.name }) : super(id); final String name; bool get isLocalEmulator => true; String get xcrunPath => path.join('/usr', 'bin', 'xcrun'); String _getSimulatorPath() { return path.join(homeDirectory, 'Library', 'Developer', 'CoreSimulator', 'Devices', id); } String _getSimulatorAppHomeDirectory(ApplicationPackage app) { String simulatorPath = _getSimulatorPath(); if (simulatorPath == null) return null; return path.join(simulatorPath, 'data'); } @override bool installApp(ApplicationPackage app) { try { SimControl.instance.install(id, app.localPath); return true; } catch (e) { return false; } } @override bool isSupported() { if (!Platform.isMacOS) { _supportMessage = "Not supported on a non Mac host"; return false; } // Step 1: Check if the device is part of a blacklisted category. // We do not support WatchOS or tvOS devices. RegExp blacklist = new RegExp(r'Apple (TV|Watch)', caseSensitive: false); if (blacklist.hasMatch(name)) { _supportMessage = "Flutter does not support either the Apple TV or Watch. Choose an iPhone 5s or above."; return false; } // Step 2: Check if the device must be rejected because of its version. // There is an artitifical check on older simulators where arm64 // targetted applications cannot be run (even though the // Flutter runner on the simulator is completely different). RegExp versionExp = new RegExp(r'iPhone ([0-9])+'); Match match = versionExp.firstMatch(name); if (match == null) { // Not an iPhone. All available non-iPhone simulators are compatible. return true; } if (int.parse(match.group(1)) > 5) { // iPhones 6 and above are always fine. return true; } // The 's' subtype of 5 is compatible. if (name.contains('iPhone 5s')) { return true; } _supportMessage = "The simulator version is too old. Choose an iPhone 5s or above."; return false; } String _supportMessage; @override String supportMessage() { if (isSupported()) { return "Supported"; } return _supportMessage != null ? _supportMessage : "Unknown"; } @override bool isAppInstalled(ApplicationPackage app) { try { String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app); return FileSystemEntity.isDirectorySync(simulatorHomeDirectory); } catch (e) { return false; } } @override Future<bool> startApp( ApplicationPackage app, Toolchain toolchain, { String mainPath, String route, bool checked: true, bool clearLogs: false, bool startPaused: false, int debugPort: observatoryDefaultPort, Map<String, dynamic> platformArgs }) async { printTrace('Building ${app.name} for $id.'); if (clearLogs) this.clearLogs(); if (!(await _setupUpdatedApplicationBundle(app, toolchain))) return false; // Prepare launch arguments. List<String> args = <String>[ "--flx=${path.absolute(path.join('build', 'app.flx'))}", "--dart-main=${path.absolute(mainPath)}", "--package-root=${path.absolute('packages')}", ]; if (checked) args.add("--enable-checked-mode"); if (startPaused) args.add("--start-paused"); if (debugPort != observatoryDefaultPort) args.add("--observatory-port=$debugPort"); // Launch the updated application in the simulator. try { SimControl.instance.launch(id, app.id, args); } catch (error) { printError('$error'); return false; } printTrace('Successfully started ${app.name} on $id.'); return true; } bool _applicationIsInstalledAndRunning(ApplicationPackage app) { bool isInstalled = exitsHappy([ 'xcrun', 'simctl', 'get_app_container', 'booted', app.id, ]); bool isRunning = exitsHappy([ '/usr/bin/killall', 'Runner', ]); return isInstalled && isRunning; } Future<bool> _setupUpdatedApplicationBundle(ApplicationPackage app, Toolchain toolchain) async { bool sideloadResult = await _sideloadUpdatedAssetsForInstalledApplicationBundle(app, toolchain); if (!sideloadResult) return false; if (!_applicationIsInstalledAndRunning(app)) return _buildAndInstallApplicationBundle(app); return true; } Future<bool> _buildAndInstallApplicationBundle(ApplicationPackage app) async { // Step 1: Build the Xcode project. bool buildResult = await buildIOSXcodeProject(app, buildForDevice: false); if (!buildResult) { printError('Could not build the application for the simulator.'); return false; } // Step 2: Assert that the Xcode project was successfully built. Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphonesimulator', 'Runner.app')); bool bundleExists = await bundle.exists(); if (!bundleExists) { printError('Could not find the built application bundle at ${bundle.path}.'); return false; } // Step 3: Install the updated bundle to the simulator. SimControl.instance.install(id, path.absolute(bundle.path)); return true; } Future<bool> _sideloadUpdatedAssetsForInstalledApplicationBundle( ApplicationPackage app, Toolchain toolchain) async { return (await flx.build(toolchain, precompiledSnapshot: true)) == 0; } @override Future<bool> stopApp(ApplicationPackage app) async { // Currently we don't have a way to stop an app running on iOS. return false; } Future<bool> pushFile( ApplicationPackage app, String localFile, String targetFile) async { if (Platform.isMacOS) { String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app); runCheckedSync(<String>['cp', localFile, path.join(simulatorHomeDirectory, targetFile)]); return true; } return false; } String get logFilePath { return path.join(homeDirectory, 'Library', 'Logs', 'CoreSimulator', id, 'system.log'); } @override TargetPlatform get platform => TargetPlatform.iOSSimulator; DeviceLogReader createLogReader() => new _IOSSimulatorLogReader(this); void clearLogs() { File logFile = new File(logFilePath); if (logFile.existsSync()) { RandomAccessFile randomFile = logFile.openSync(mode: FileMode.WRITE); randomFile.truncateSync(0); randomFile.closeSync(); } } void ensureLogsExists() { File logFile = new File(logFilePath); if (!logFile.existsSync()) logFile.writeAsBytesSync(<int>[]); } } class _IOSSimulatorLogReader extends DeviceLogReader { _IOSSimulatorLogReader(this.device); final IOSSimulator device; bool _lastWasFiltered = false; String get name => device.name; Future<int> logs({ bool clear: false, bool showPrefix: false }) async { if (clear) device.clearLogs(); device.ensureLogsExists(); // Match the log prefix (in order to shorten it): // 'Jan 29 01:31:44 devoncarew-macbookpro3 SpringBoard[96648]: ...' RegExp mapRegex = new RegExp(r'\S+ +\S+ +\S+ \S+ (.+)\[\d+\]\)?: (.*)$'); // Jan 31 19:23:28 --- last message repeated 1 time --- RegExp lastMessageRegex = new RegExp(r'\S+ +\S+ +\S+ --- (.*) ---$'); // This filter matches many Flutter lines in the log: // new RegExp(r'(FlutterRunner|flutter.runner.Runner|$id)'), but it misses // a fair number, including ones that would be useful in diagnosing crashes. // For now, we're not filtering the log file (but do clear it with each run). Future<int> result = runCommandAndStreamOutput( <String>['tail', '-n', '+0', '-F', device.logFilePath], prefix: showPrefix ? '[$name] ' : '', mapFunction: (String string) { Match match = mapRegex.matchAsPrefix(string); if (match != null) { _lastWasFiltered = true; // Filter out some messages that clearly aren't related to Flutter. if (string.contains(': could not find icon for representation -> com.apple.')) return null; String category = match.group(1); String content = match.group(2); if (category == 'Game Center' || category == 'itunesstored' || category == 'nanoregistrylaunchd' || category == 'mstreamd' || category == 'syncdefaultsd' || category == 'companionappd' || category == 'searchd') return null; _lastWasFiltered = false; if (category == 'Runner') return content; return '$category: $content'; } match = lastMessageRegex.matchAsPrefix(string); if (match != null && !_lastWasFiltered) return '(${match.group(1)})'; return string; } ); // Track system.log crashes. // ReportCrash[37965]: Saved crash report for FlutterRunner[37941]... runCommandAndStreamOutput( <String>['tail', '-F', '/private/var/log/system.log'], prefix: showPrefix ? '[$name] ' : '', filter: new RegExp(r' FlutterRunner\[\d+\] '), mapFunction: (String string) { Match match = mapRegex.matchAsPrefix(string); return match == null ? string : '${match.group(1)}: ${match.group(2)}'; } ); return await result; } int get hashCode => device.logFilePath.hashCode; bool operator ==(dynamic other) { if (identical(this, other)) return true; if (other is! _IOSSimulatorLogReader) return false; return other.device.logFilePath == device.logFilePath; } }