blob: 396582f26a58d4c348557948e16d97d5199a3edd [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';
import 'dart:io';
import '../application_package.dart';
import '../base/os.dart';
import '../base/process.dart';
import '../build_info.dart';
import '../device.dart';
import '../globals.dart';
import '../protocol_discovery.dart';
import 'mac.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".';
const Duration kPortForwardTimeout = const Duration(seconds: 10);
class IOSDevices extends PollingDeviceDiscovery {
IOSDevices() : super('IOSDevices');
@override
bool get supportsPlatform => Platform.isMacOS;
@override
List<Device> pollingGetDevices() => IOSDevice.getAttachedDevices();
}
class IOSDevice extends Device {
IOSDevice(String id, { this.name }) : super(id) {
_installerPath = _checkForCommand('ideviceinstaller');
_listerPath = _checkForCommand('idevice_id');
_informerPath = _checkForCommand('ideviceinfo');
_iproxyPath = _checkForCommand('iproxy');
_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 _iproxyPath;
String get iproxyPath => _iproxyPath;
String _debuggerPath;
String get debuggerPath => _debuggerPath;
String _loggerPath;
String get loggerPath => _loggerPath;
String _pusherPath;
String get pusherPath => _pusherPath;
@override
bool get supportsHotMode => true;
@override
final String name;
Map<ApplicationPackage, _IOSDeviceLogReader> _logReaders;
_IOSDevicePortForwarder _portForwarder;
@override
bool get isLocalEmulator => false;
@override
bool get supportsStartPaused => false;
static List<IOSDevice> getAttachedDevices([IOSDevice mockIOS]) {
if (!doctor.iosWorkflow.hasIDeviceId)
return <IOSDevice>[];
List<IOSDevice> devices = <IOSDevice>[];
for (String id in _getAttachedDeviceIDs(mockIOS)) {
String name = IOSDevice._getDeviceInfo(id, 'DeviceName', 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(<String>[listerPath, '-l']);
return output.trim().split('\n').where((String s) => s != null && s.isNotEmpty);
} catch (e) {
return <String>[];
}
}
static String _getDeviceInfo(String deviceID, String infoKey, [IOSDevice mockIOS]) {
String informerPath = (mockIOS != null)
? mockIOS.informerPath
: _checkForCommand('ideviceinfo');
return runSync(<String>[informerPath, '-k', infoKey, '-u', deviceID]).trim();
}
static final Map<String, String> _commandMap = <String, String>{};
static String _checkForCommand(
String command, [
String macInstructions = _ideviceinstallerInstructions
]) {
return _commandMap.putIfAbsent(command, () {
try {
command = runCheckedSync(<String>['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 isAppInstalled(ApplicationPackage app) {
try {
String apps = runCheckedSync(<String>[installerPath, '--list-apps']);
if (new RegExp(app.id, multiLine: true).hasMatch(apps)) {
return true;
}
} catch (e) {
return false;
}
return false;
}
@override
bool installApp(ApplicationPackage app) {
IOSApp iosApp = app;
Directory bundle = new Directory(iosApp.deviceBundlePath);
if (!bundle.existsSync()) {
printError("Could not find application bundle at ${bundle.path}; have you run 'flutter build ios'?");
return false;
}
try {
runCheckedSync(<String>[installerPath, '-i', iosApp.deviceBundlePath]);
return true;
} catch (e) {
return false;
}
}
@override
bool uninstallApp(ApplicationPackage app) {
try {
runCheckedSync(<String>[installerPath, '-U', app.id]);
return true;
} catch (e) {
return false;
}
}
@override
bool isSupported() => true;
@override
Future<LaunchResult> startApp(
ApplicationPackage app,
BuildMode mode, {
String mainPath,
String route,
DebuggingOptions debuggingOptions,
Map<String, dynamic> platformArgs,
bool prebuiltApplication: false
}) async {
if (!prebuiltApplication) {
// TODO(chinmaygarde): Use checked, mainPath, route.
// TODO(devoncarew): Handle startPaused, debugPort.
printTrace('Building ${app.name} for $id');
// Step 1: Build the precompiled/DBC application if necessary.
XcodeBuildResult buildResult = await buildXcodeProject(app: app, mode: mode, target: mainPath, buildForDevice: true);
if (!buildResult.success) {
printError('Could not build the precompiled application for the device.');
diagnoseXcodeBuildFailure(buildResult);
printError('');
return new LaunchResult.failed();
}
}
// Step 2: Check that the application exists at the specified path.
IOSApp iosApp = app;
Directory bundle = new Directory(iosApp.deviceBundlePath);
if (!bundle.existsSync()) {
printError('Could not find the built application bundle at ${bundle.path}.');
return new LaunchResult.failed();
}
// Step 3: Attempt to install the application on the device.
List<String> launchArguments = <String>[];
if (debuggingOptions.startPaused)
launchArguments.add("--start-paused");
if (debuggingOptions.debuggingEnabled) {
launchArguments.add("--enable-checked-mode");
// Note: We do NOT need to set the observatory port since this is going to
// be setup on the device. Let it pick a port automatically. We will check
// the port picked and scrape that later.
}
if (platformArgs['trace-startup'] ?? false)
launchArguments.add('--trace-startup');
List<String> launchCommand = <String>[
'/usr/bin/env',
'ios-deploy',
'--id',
id,
'--bundle',
bundle.path,
'--justlaunch',
];
if (launchArguments.length > 0) {
launchCommand.add('--args');
launchCommand.add('${launchArguments.join(" ")}');
}
int installationResult = -1;
int localObsPort;
int localDiagPort;
if (!debuggingOptions.debuggingEnabled) {
// If debugging is not enabled, just launch the application and continue.
printTrace("Debugging is not enabled");
installationResult = await runCommandAndStreamOutput(launchCommand, trace: true);
} else {
// Debugging is enabled, look for the observatory and diagnostic server
// ports post launch.
printTrace("Debugging is enabled, connecting to observatory and the diagnostic server");
Future<int> forwardObsPort = _acquireAndForwardPort(app,
ProtocolDiscovery.kObservatoryService,
debuggingOptions.observatoryPort);
Future<int> forwardDiagPort;
if (debuggingOptions.buildMode == BuildMode.debug) {
forwardDiagPort = _acquireAndForwardPort(app,
ProtocolDiscovery.kDiagnosticService,
debuggingOptions.diagnosticPort);
} else {
forwardDiagPort = new Future<int>.value(null);
}
Future<int> launch = runCommandAndStreamOutput(launchCommand, trace: true);
List<int> ports = await launch.then((int result) async {
installationResult = result;
if (result != 0) {
printTrace("Failed to launch the application on device.");
return <int>[null, null];
}
printTrace("Application launched on the device. Attempting to forward ports.");
return await Future.wait(<Future<int>>[forwardObsPort, forwardDiagPort])
.timeout(
kPortForwardTimeout,
onTimeout: () {
throw new TimeoutException('Timeout while waiting to acquire and forward ports.');
},
);
});
printTrace("Local Observatory Port: ${ports[0]}");
printTrace("Local Diagnostic Server Port: ${ports[1]}");
localObsPort = ports[0];
localDiagPort = ports[1];
}
if (installationResult != 0) {
printError('Could not install ${bundle.path} on $id.');
printError("Try launching XCode and selecting 'Product > Run' to fix the problem:");
printError(" open ios/Runner.xcodeproj");
printError('');
return new LaunchResult.failed();
}
return new LaunchResult.succeeded(observatoryPort: localObsPort, diagnosticPort: localDiagPort);
}
Future<int> _acquireAndForwardPort(
ApplicationPackage app,
String serviceName,
int localPort) async {
Duration stepTimeout = const Duration(seconds: 60);
Future<int> remote = new ProtocolDiscovery(getLogReader(app: app), serviceName).nextPort();
int remotePort = await remote.timeout(stepTimeout,
onTimeout: () {
printTrace("Timeout while attempting to retrieve remote port for $serviceName");
return null;
});
if (remotePort == null) {
printTrace("Could not read port on device for $serviceName");
return null;
}
if ((localPort == null) || (localPort == 0)) {
localPort = await findAvailablePort();
printTrace("Auto selected local port to $localPort");
}
int forwardResult = await portForwarder.forward(remotePort,
hostPort: localPort).timeout(stepTimeout, onTimeout: () {
printTrace("Timeout while atempting to foward port for $serviceName");
return null;
});
if (forwardResult == null) {
printTrace("Could not foward remote $serviceName port $remotePort to local port $localPort");
return null;
}
printStatus('$serviceName listening on http://127.0.0.1:$localPort');
return localPort;
}
@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;
}
}
@override
TargetPlatform get platform => TargetPlatform.ios;
@override
String get sdkNameAndVersion => 'iOS $_sdkVersion ($_buildVersion)';
String get _sdkVersion => _getDeviceInfo(id, 'ProductVersion');
String get _buildVersion => _getDeviceInfo(id, 'BuildVersion');
@override
DeviceLogReader getLogReader({ApplicationPackage app}) {
_logReaders ??= <ApplicationPackage, _IOSDeviceLogReader>{};
return _logReaders.putIfAbsent(app, () => new _IOSDeviceLogReader(this, app));
}
@override
DevicePortForwarder get portForwarder {
if (_portForwarder == null)
_portForwarder = new _IOSDevicePortForwarder(this);
return _portForwarder;
}
@override
void clearLogs() {
}
@override
bool get supportsScreenshot => false;
@override
Future<bool> takeScreenshot(File outputFile) {
// We could use idevicescreenshot here (installed along with the brew
// ideviceinstaller tools). It however requires a developer disk image on
// the device.
return new Future<bool>.value(false);
}
}
class _IOSDeviceLogReader extends DeviceLogReader {
RegExp _lineRegex;
_IOSDeviceLogReader(this.device, ApplicationPackage app) {
_linesController = new StreamController<String>.broadcast(
onListen: _start,
onCancel: _stop
);
// Match for lines for the runner in syslog.
//
// iOS 9 format: Runner[297] <Notice>:
// iOS 10 format: Runner(libsystem_asl.dylib)[297] <Notice>:
String appName = app == null ? '' : app.name.replaceAll('.app', '');
_lineRegex = new RegExp(appName + r'(\(.*\))?\[[\d]+\] <[A-Za-z]+>: ');
}
final IOSDevice device;
StreamController<String> _linesController;
Process _process;
@override
Stream<String> get logLines => _linesController.stream;
@override
String get name => device.name;
void _start() {
runCommand(<String>[device.loggerPath]).then((Process process) {
_process = process;
_process.stdout.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onLine);
_process.stderr.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onLine);
_process.exitCode.then((int code) {
if (_linesController.hasListener)
_linesController.close();
});
});
}
void _onLine(String line) {
Match match = _lineRegex.firstMatch(line);
if (match != null) {
// Only display the log line after the initial device and executable information.
_linesController.add(line.substring(match.end));
}
}
void _stop() {
_process?.kill();
}
}
class _IOSDevicePortForwarder extends DevicePortForwarder {
_IOSDevicePortForwarder(this.device) : _forwardedPorts = new List<ForwardedPort>();
final IOSDevice device;
final List<ForwardedPort> _forwardedPorts;
@override
List<ForwardedPort> get forwardedPorts => _forwardedPorts;
@override
Future<int> forward(int devicePort, {int hostPort: null}) async {
if ((hostPort == null) || (hostPort == 0)) {
// Auto select host port.
hostPort = await findAvailablePort();
}
// Usage: iproxy LOCAL_TCP_PORT DEVICE_TCP_PORT UDID
Process process = await runCommand(<String>[
device.iproxyPath,
hostPort.toString(),
devicePort.toString(),
device.id,
]);
ForwardedPort forwardedPort = new ForwardedPort.withContext(hostPort,
devicePort, process);
printTrace("Forwarded port $forwardedPort");
_forwardedPorts.add(forwardedPort);
return 1;
}
@override
Future<Null> unforward(ForwardedPort forwardedPort) async {
if (!_forwardedPorts.remove(forwardedPort)) {
// Not in list. Nothing to remove.
return null;
}
printTrace("Unforwarding port $forwardedPort");
Process process = forwardedPort.context;
if (process != null) {
Process.killPid(process.pid);
} else {
printError("Forwarded port did not have a valid process");
}
return null;
}
}