blob: 2ee184665d083ae374bd2a8cd7405773cf0be45a [file] [log] [blame]
// 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 '../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.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 {
// TODO(chinmaygarde): Use mainPath, route.
printTrace('Building ${app.name} for $id.');
if (clearLogs)
this.clearLogs();
// 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));
// Step 4: Prepare launch arguments.
List<String> args = <String>[];
if (checked)
args.add("--enable-checked-mode");
if (startPaused)
args.add("--start-paused");
if (debugPort != observatoryDefaultPort)
args.add("--observatory-port=$debugPort");
// Step 5: 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;
}
@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;
}
}