blob: aa487627d23d8281eda6873ea67986940d031551 [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:io';
import 'dart:convert';
import 'package:path/path.dart' as path;
import '../application_package.dart';
import '../artifacts.dart';
import '../base/common.dart';
import '../base/globals.dart';
import '../base/process.dart';
import '../build_configuration.dart';
import '../device.dart';
import '../services.dart';
import '../toolchain.dart';
import '../ios/setup_xcodeproj.dart';
import 'simulator.dart';
const String _ideviceinstallerInstructions =
'To work with iOS devices, please install ideviceinstaller.\n'
'If you use homebrew, you can install it with "\$ brew install ideviceinstaller".';
class IOSDeviceDiscovery extends DeviceDiscovery {
List<Device> _devices = <Device>[];
bool get supportsPlatform => Platform.isMacOS;
Future init() {
_devices = IOSDevice.getAttachedDevices();
return new Future.value();
}
List<Device> get devices => _devices;
}
class IOSSimulatorDiscovery extends DeviceDiscovery {
List<Device> _devices = <Device>[];
bool get supportsPlatform => Platform.isMacOS;
Future init() {
_devices = IOSSimulator.getAttachedDevices();
return new Future.value();
}
List<Device> get devices => _devices;
}
class IOSDevice extends Device {
IOSDevice(String id, { this.name }) : super(id) {
_installerPath = _checkForCommand('ideviceinstaller');
_listerPath = _checkForCommand('idevice_id');
_informerPath = _checkForCommand('ideviceinfo');
_debuggerPath = _checkForCommand('idevicedebug');
_loggerPath = _checkForCommand('idevicesyslog');
_pusherPath = _checkForCommand(
'ios-deploy',
'To copy files to iOS devices, please install ios-deploy. '
'You can do this using homebrew as follows:\n'
'\$ brew tap flutter/flutter\n'
'\$ brew install ios-deploy');
}
String _installerPath;
String get installerPath => _installerPath;
String _listerPath;
String get listerPath => _listerPath;
String _informerPath;
String get informerPath => _informerPath;
String _debuggerPath;
String get debuggerPath => _debuggerPath;
String _loggerPath;
String get loggerPath => _loggerPath;
String _pusherPath;
String get pusherPath => _pusherPath;
final String name;
bool get supportsStartPaused => false;
static List<IOSDevice> getAttachedDevices([IOSDevice mockIOS]) {
List<IOSDevice> devices = [];
for (String id in _getAttachedDeviceIDs(mockIOS)) {
String name = _getDeviceName(id, mockIOS);
devices.add(new IOSDevice(id, name: name));
}
return devices;
}
static Iterable<String> _getAttachedDeviceIDs([IOSDevice mockIOS]) {
String listerPath = (mockIOS != null) ? mockIOS.listerPath : _checkForCommand('idevice_id');
try {
String output = runSync([listerPath, '-l']);
return output.trim().split('\n').where((String s) => s != null && s.isNotEmpty);
} catch (e) {
return <String>[];
}
}
static String _getDeviceName(String deviceID, [IOSDevice mockIOS]) {
String informerPath = (mockIOS != null)
? mockIOS.informerPath
: _checkForCommand('ideviceinfo');
return runSync([informerPath, '-k', 'DeviceName', '-u', deviceID]).trim();
}
static final Map<String, String> _commandMap = {};
static String _checkForCommand(
String command, [
String macInstructions = _ideviceinstallerInstructions
]) {
return _commandMap.putIfAbsent(command, () {
try {
command = runCheckedSync(['which', command]).trim();
} catch (e) {
if (Platform.isMacOS) {
printError('$command not found. $macInstructions');
} else {
printError('Cannot control iOS devices or simulators. $command is not available on your platform.');
}
}
return command;
});
}
@override
bool installApp(ApplicationPackage app) {
try {
runCheckedSync([installerPath, '-i', app.localPath]);
return true;
} catch (e) {
return false;
}
return false;
}
@override
bool isConnected() => _getAttachedDeviceIDs().contains(id);
@override
bool isAppInstalled(ApplicationPackage app) {
try {
String apps = runCheckedSync([installerPath, '--list-apps']);
if (new RegExp(app.id, multiLine: true).hasMatch(apps)) {
return true;
}
} catch (e) {
return false;
}
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 checked, mainPath, route, clearLogs.
// TODO(devoncarew): Handle startPaused, debugPort.
printTrace('Building ${app.name} for $id');
// Step 1: Install the precompiled application if necessary.
bool buildResult = await _buildIOSXcodeProject(app, buildForDevice: true);
if (!buildResult) {
printError('Could not build the precompiled application for the device.');
return false;
}
// Step 2: Check that the application exists at the specified path.
Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphoneos', 'Runner.app'));
bool bundleExists = bundle.existsSync();
if (!bundleExists) {
printError('Could not find the built application bundle at ${bundle.path}.');
return false;
}
// Step 3: Attempt to install the application on the device.
int installationResult = await runCommandAndStreamOutput([
'/usr/bin/env',
'ios-deploy',
'--id',
id,
'--bundle',
bundle.path,
]);
if (installationResult != 0) {
printError('Could not install ${bundle.path} on $id.');
return false;
}
printTrace('Installation successful.');
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) {
runSync(<String>[
pusherPath,
'-t',
'1',
'--bundle_id',
app.id,
'--upload',
localFile,
'--to',
targetFile
]);
return true;
} else {
return false;
}
return false;
}
@override
TargetPlatform get platform => TargetPlatform.iOS;
DeviceLogReader createLogReader() => new _IOSDeviceLogReader(this);
}
class IOSSimulator extends Device {
IOSSimulator(String id, { this.name }) : super(id);
static List<IOSSimulator> getAttachedDevices() {
return SimControl.getConnectedDevices().map((SimDevice device) {
return new IOSSimulator(device.udid, name: device.name);
}).toList();
}
final String name;
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) {
if (!isConnected())
return false;
try {
SimControl.install(id, app.localPath);
return true;
} catch (e) {
return false;
}
}
@override
bool isConnected() {
if (!Platform.isMacOS)
return false;
return SimControl.getConnectedDevices().any((SimDevice device) => device.udid == id);
}
@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.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.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 _IOSDeviceLogReader extends DeviceLogReader {
_IOSDeviceLogReader(this.device);
final IOSDevice device;
String get name => device.name;
// TODO(devoncarew): Support [clear].
Future<int> logs({ bool clear: false }) async {
if (!device.isConnected())
return 2;
// TODO(devoncarew): This regex should use the CFBundleIdentifier value from
// the user's plist (instead of `flutter.runner.Runner`).
return await runCommandAndStreamOutput(
<String>[device.loggerPath],
prefix: '[$name] ',
filter: new RegExp(r'(FlutterRunner|flutter.runner.Runner)')
);
}
int get hashCode => name.hashCode;
bool operator ==(dynamic other) {
if (identical(this, other))
return true;
if (other is! _IOSDeviceLogReader)
return false;
return other.name == name;
}
}
class _IOSSimulatorLogReader extends DeviceLogReader {
_IOSSimulatorLogReader(this.device);
final IOSSimulator device;
bool _lastWasFiltered = false;
String get name => device.name;
Future<int> logs({ bool clear: false }) async {
if (!device.isConnected())
return 2;
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: '[$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 == 'FlutterRunner' || 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: '[$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;
}
}
final RegExp _xcodeVersionRegExp = new RegExp(r'Xcode (\d+)\..*');
final String _xcodeRequirement = 'Xcode 7.0 or greater is required to develop for iOS.';
String get _homeDirectory => path.absolute(Platform.environment['HOME']);
bool _checkXcodeVersion() {
if (!Platform.isMacOS)
return false;
try {
String version = runCheckedSync(<String>['xcodebuild', '-version']);
Match match = _xcodeVersionRegExp.firstMatch(version);
if (int.parse(match[1]) < 7) {
printError('Found "${match[0]}". $_xcodeRequirement');
return false;
}
} catch (e) {
printError('Cannot find "xcodebuid". $_xcodeRequirement');
return false;
}
return true;
}
bool _validateEngineRevision(ApplicationPackage app) {
String skyRevision = ArtifactStore.engineRevision;
String iosRevision = _getIOSEngineRevision(app);
if (iosRevision != skyRevision) {
printError("Error: incompatible sky_engine revision; please run 'flutter ios --init' to update.");
printStatus('sky_engine revision: $skyRevision, iOS engine revision: $iosRevision');
return false;
} else {
printTrace('sky_engine revision: $skyRevision, iOS engine revision: $iosRevision');
return true;
}
}
String _getIOSEngineRevision(ApplicationPackage app) {
File revisionFile = new File(path.join(app.localPath, 'REVISION'));
if (revisionFile.existsSync()) {
return revisionFile.readAsStringSync().trim();
} else {
return null;
}
}
Future<bool> _buildIOSXcodeProject(ApplicationPackage app, { bool buildForDevice }) async {
if (!FileSystemEntity.isDirectorySync(app.localPath)) {
printTrace('Path "${path.absolute(app.localPath)}" does not exist. Initializing the Xcode project.');
if ((await setupXcodeProjectHarness()) != 0) {
printError('Could not initialize the Xcode project.');
return false;
}
}
if (!_validateEngineRevision(app))
return false;
if (!_checkXcodeVersion())
return false;
// Before the build, all service definitions must be updated and the dylibs
// copied over to a location that is suitable for Xcodebuild to find them.
await _addServicesToBundle(new Directory(app.localPath));
List<String> commands = <String>[
'/usr/bin/env', 'xcrun', 'xcodebuild', '-target', 'Runner', '-configuration', 'Release'
];
if (buildForDevice) {
commands.addAll(<String>['-sdk', 'iphoneos', '-arch', 'arm64']);
} else {
commands.addAll(<String>['-sdk', 'iphonesimulator', '-arch', 'x86_64']);
}
try {
runCheckedSync(commands, workingDirectory: app.localPath);
return true;
} catch (error) {
return false;
}
}
Future _addServicesToBundle(Directory bundle) async {
List<Map<String, String>> services = [];
printTrace("Trying to resolve native pub services.");
// Step 1: Parse the service configuration yaml files present in the service
// pub packages.
await parseServiceConfigs(services);
printTrace("Found ${services.length} service definition(s).");
// Step 2: Copy framework dylibs to the correct spot for xcodebuild to pick up.
Directory frameworksDirectory = new Directory(path.join(bundle.path, "Frameworks"));
await _copyServiceFrameworks(services, frameworksDirectory);
// Step 3: Copy the service definitions manifest at the correct spot for
// xcodebuild to pick up.
File manifestFile = new File(path.join(bundle.path, "ServiceDefinitions.json"));
_copyServiceDefinitionsManifest(services, manifestFile);
}
Future _copyServiceFrameworks(List<Map<String, String>> services, Directory frameworksDirectory) async {
printTrace("Copying service frameworks to '${path.absolute(frameworksDirectory.path)}'.");
frameworksDirectory.createSync(recursive: true);
for (Map<String, String> service in services) {
String dylibPath = await getServiceFromUrl(service['ios-framework'], service['root'], service['name']);
File dylib = new File(dylibPath);
printTrace("Copying ${dylib.path} into bundle.");
if (!dylib.existsSync()) {
printError("The service dylib '${dylib.path}' does not exist.");
continue;
}
// Shell out so permissions on the dylib are preserved.
runCheckedSync(['/bin/cp', dylib.path, frameworksDirectory.path]);
}
}
void _copyServiceDefinitionsManifest(List<Map<String, String>> services, File manifest) {
printTrace("Creating service definitions manifest at '${manifest.path}'");
List<Map<String, String>> jsonServices = services.map((Map<String, String> service) => {
'name': service['name'],
// Since we have already moved it to the Frameworks directory. Strip away
// the directory and basenames.
'framework': path.basenameWithoutExtension(service['ios-framework'])
}).toList();
Map<String, dynamic> json = { 'services' : jsonServices };
manifest.writeAsStringSync(JSON.encode(json), mode: FileMode.WRITE, flush: true);
}