blob: 2cb71bd6919843e5103940d7021932f6f299f9ce [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 'dart:math' as math;
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_info.dart';
import '../device.dart';
import '../flx.dart' as flx;
import '../globals.dart';
import '../protocol_discovery.dart';
import 'mac.dart';
const String _xcrunPath = '/usr/bin/xcrun';
/// Test device created by Flutter when no other device is available.
const String _kFlutterTestDeviceSuffix = '(Flutter)';
class IOSSimulators extends PollingDeviceDiscovery {
IOSSimulators() : super('IOSSimulators');
@override
bool get supportsPlatform => Platform.isMacOS;
@override
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, category: device.category);
}).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 deviceName }) async {
if (_isAnyConnected())
return true;
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. -l causes xcrun to
// quit after a time limit without killing the simulator. We quit after
// 1 second.
List<String> args = <String>[_xcrunPath, 'instruments', '-w', deviceName, '-t', 'Blank', '-l', '1'];
printTrace(args.join(' '));
runDetached(args);
printStatus('Waiting for iOS Simulator to boot...');
bool connected = false;
int attempted = 0;
while (!connected && attempted < 20) {
connected = _isAnyConnected();
if (!connected) {
printStatus('Still waiting for iOS Simulator to boot...');
await new Future<Null>.delayed(new Duration(seconds: 1));
}
attempted++;
}
if (connected) {
printStatus('Connected to iOS Simulator.');
return true;
} else {
printStatus('Timed out waiting for iOS Simulator to boot.');
return false;
}
}
SimDevice _createTestDevice() {
SimDeviceType deviceType = _findSuitableDeviceType();
if (deviceType == null)
return null;
String runtime = _findSuitableRuntime();
if (runtime == null)
return null;
// Delete any old test devices
getDevices()
.where((SimDevice d) => d.name.endsWith(_kFlutterTestDeviceSuffix))
.forEach(_deleteDevice);
// Create new device
String deviceName = '${deviceType.name} $_kFlutterTestDeviceSuffix';
List<String> args = <String>[_xcrunPath, 'simctl', 'create', deviceName, deviceType.identifier, runtime];
printTrace(args.join(' '));
runCheckedSync(args);
return getDevices().firstWhere((SimDevice d) => d.name == deviceName);
}
SimDeviceType _findSuitableDeviceType() {
List<Map<String, dynamic>> allTypes = _list(SimControlListSection.devicetypes);
List<Map<String, dynamic>> usableTypes = allTypes
.where((Map<String, dynamic> info) => info['name'].startsWith('iPhone'))
.toList()
..sort((Map<String, dynamic> r1, Map<String, dynamic> 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 new SimDeviceType(
usableTypes.first['name'],
usableTypes.first['identifier']
);
}
String _findSuitableRuntime() {
List<Map<String, dynamic>> allRuntimes = _list(SimControlListSection.runtimes);
List<Map<String, dynamic>> usableRuntimes = allRuntimes
.where((Map<String, dynamic> info) => info['name'].startsWith('iOS'))
.toList()
..sort((Map<String, dynamic> r1, Map<String, dynamic> 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<String> args = <String>[_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" : [
// {
// "state" : "Shutdown",
// "availability" : " (unavailable, runtime profile not found)",
// "name" : "iPhone 4s",
// "udid" : "1913014C-6DCB-485D-AC6B-7CD76D322F5B"
// },
// ...
// },
// "pairs": { ... },
List<String> args = <String>['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 <String, Map<String, dynamic>>{};
}
return JSON.decode(results.stdout)[section.name];
}
/// Returns a list of all available devices, both potential and connected.
List<SimDevice> getDevices() {
List<SimDevice> devices = <SimDevice>[];
Map<String, dynamic> devicesSection = _list(SimControlListSection.devices);
for (String deviceCategory in devicesSection.keys) {
List<Map<String, String>> 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();
}
bool _isAnyConnected() => getConnectedDevices().isNotEmpty;
bool isInstalled(String appId) {
return exitsHappy(<String>[
_xcrunPath,
'simctl',
'get_app_container',
'booted',
appId,
]);
}
void install(String deviceId, String appPath) {
runCheckedSync(<String>[_xcrunPath, 'simctl', 'install', deviceId, appPath]);
}
void uninstall(String deviceId, String appId) {
runCheckedSync(<String>[_xcrunPath, 'simctl', 'uninstall', deviceId, appId]);
}
void launch(String deviceId, String appIdentifier, [List<String> launchArgs]) {
List<String> args = <String>[_xcrunPath, 'simctl', 'launch', deviceId, appIdentifier];
if (launchArgs != null)
args.addAll(launchArgs);
runCheckedSync(args);
}
}
/// Enumerates all data sections of `xcrun simctl list --json` command.
class SimControlListSection {
const SimControlListSection._(this.name);
final String name;
static const SimControlListSection devices = const SimControlListSection._('devices');
static const SimControlListSection devicetypes = const SimControlListSection._('devicetypes');
static const SimControlListSection runtimes = const SimControlListSection._('runtimes');
static const SimControlListSection pairs = const SimControlListSection._('pairs');
}
/// A simulated device type.
///
/// Simulated device types can be listed using the command
/// `xcrun simctl list devicetypes`.
class SimDeviceType {
SimDeviceType(this.name, this.identifier);
/// The name of the device type.
///
/// Examples:
///
/// "iPhone 6s"
/// "iPhone 6 Plus"
final String name;
/// The identifier of the device type.
///
/// Examples:
///
/// "com.apple.CoreSimulator.SimDeviceType.iPhone-6s"
/// "com.apple.CoreSimulator.SimDeviceType.iPhone-6-Plus"
final String identifier;
}
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, this.category }) : super(id);
@override
final String name;
final String category;
@override
bool get isLocalEmulator => true;
@override
bool get supportsHotMode => true;
Map<ApplicationPackage, _IOSSimulatorLogReader> _logReaders;
_IOSSimulatorDevicePortForwarder _portForwarder;
String get xcrunPath => path.join('/usr', 'bin', 'xcrun');
String _getSimulatorPath() {
return path.join(homeDirPath, 'Library', 'Developer', 'CoreSimulator', 'Devices', id);
}
String _getSimulatorAppHomeDirectory(ApplicationPackage app) {
String simulatorPath = _getSimulatorPath();
if (simulatorPath == null)
return null;
return path.join(simulatorPath, 'data');
}
@override
bool isAppInstalled(ApplicationPackage app) {
return SimControl.instance.isInstalled(app.id);
}
@override
bool installApp(ApplicationPackage app) {
try {
IOSApp iosApp = app;
SimControl.instance.install(id, iosApp.simulatorBundlePath);
return true;
} catch (e) {
return false;
}
}
@override
bool uninstallApp(ApplicationPackage app) {
try {
SimControl.instance.uninstall(id, app.id);
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);
// Not an iPhone. All available non-iPhone simulators are compatible.
if (match == null)
return true;
// iPhones 6 and above are always fine.
if (int.parse(match.group(1)) > 5)
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
Future<LaunchResult> startApp(
ApplicationPackage app,
BuildMode mode, {
String mainPath,
String route,
DebuggingOptions debuggingOptions,
Map<String, dynamic> platformArgs,
bool prebuiltApplication: false
}) async {
if (!prebuiltApplication) {
printTrace('Building ${app.name} for $id.');
try {
await _setupUpdatedApplicationBundle(app);
} on ToolExit {
return new LaunchResult.failed();
}
}
ProtocolDiscovery observatoryDiscovery;
if (debuggingOptions.debuggingEnabled)
observatoryDiscovery = new ProtocolDiscovery(getLogReader(app: app),
ProtocolDiscovery.kObservatoryService);
// Prepare launch arguments.
List<String> args = <String>[];
if (!prebuiltApplication) {
args.addAll(<String>[
"--flx=${path.absolute(path.join(getBuildDirectory(), 'app.flx'))}",
"--dart-main=${path.absolute(mainPath)}",
"--packages=${path.absolute('.packages')}",
]);
}
if (debuggingOptions.debuggingEnabled) {
if (debuggingOptions.buildMode == BuildMode.debug)
args.add("--enable-checked-mode");
if (debuggingOptions.startPaused)
args.add("--start-paused");
int observatoryPort = await debuggingOptions.findBestObservatoryPort();
args.add("--observatory-port=$observatoryPort");
}
// Launch the updated application in the simulator.
try {
SimControl.instance.launch(id, app.id, args);
} catch (error) {
printError('$error');
return new LaunchResult.failed();
}
if (!debuggingOptions.debuggingEnabled) {
return new LaunchResult.succeeded();
} else {
// Wait for the service protocol port here. This will complete once the
// device has printed "Observatory is listening on..."
printTrace('Waiting for observatory port to be available...');
try {
int devicePort = await observatoryDiscovery
.nextPort()
.timeout(new Duration(seconds: 20));
printTrace('service protocol port = $devicePort');
printStatus('Observatory listening on http://127.0.0.1:$devicePort');
return new LaunchResult.succeeded(observatoryPort: devicePort);
} catch (error) {
if (error is TimeoutException)
printError('Timed out while waiting for a debug connection.');
else
printError('Error waiting for a debug connection: $error');
return new LaunchResult.failed();
} finally {
observatoryDiscovery.cancel();
}
}
}
bool _applicationIsInstalledAndRunning(ApplicationPackage app) {
bool isInstalled = isAppInstalled(app);
bool isRunning = exitsHappy(<String>[
'/usr/bin/killall',
'Runner',
]);
return isInstalled && isRunning;
}
Future<Null> _setupUpdatedApplicationBundle(ApplicationPackage app) async {
await _sideloadUpdatedAssetsForInstalledApplicationBundle(app);
if (!_applicationIsInstalledAndRunning(app))
return _buildAndInstallApplicationBundle(app);
}
Future<Null> _buildAndInstallApplicationBundle(ApplicationPackage app) async {
// Step 1: Build the Xcode project.
// The build mode for the simulator is always debug.
XcodeBuildResult buildResult = await buildXcodeProject(app: app, mode: BuildMode.debug, buildForDevice: false);
if (!buildResult.success)
throwToolExit('Could not build the application for the simulator.');
// Step 2: Assert that the Xcode project was successfully built.
IOSApp iosApp = app;
Directory bundle = new Directory(iosApp.simulatorBundlePath);
bool bundleExists = await bundle.exists();
if (!bundleExists)
throwToolExit('Could not find the built application bundle at ${bundle.path}.');
// Step 3: Install the updated bundle to the simulator.
SimControl.instance.install(id, path.absolute(bundle.path));
}
Future<Null> _sideloadUpdatedAssetsForInstalledApplicationBundle(ApplicationPackage app) =>
flx.build(precompiledSnapshot: 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(homeDirPath, 'Library', 'Logs', 'CoreSimulator', id, 'system.log');
}
@override
TargetPlatform get platform => TargetPlatform.ios;
@override
String get sdkNameAndVersion => category;
@override
DeviceLogReader getLogReader({ApplicationPackage app}) {
_logReaders ??= <ApplicationPackage, _IOSSimulatorLogReader>{};
return _logReaders.putIfAbsent(app, () => new _IOSSimulatorLogReader(this, app));
}
@override
DevicePortForwarder get portForwarder {
if (_portForwarder == null)
_portForwarder = new _IOSSimulatorDevicePortForwarder(this);
return _portForwarder;
}
@override
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>[]);
}
@override
bool get supportsScreenshot => true;
@override
Future<bool> takeScreenshot(File outputFile) async {
Directory desktopDir = new Directory(path.join(homeDirPath, 'Desktop'));
// 'Simulator Screen Shot Mar 25, 2016, 2.59.43 PM.png'
Set<File> getScreenshots() {
return new Set<File>.from(desktopDir.listSync().where((FileSystemEntity entity) {
String name = path.basename(entity.path);
return entity is File && name.startsWith('Simulator') && name.endsWith('.png');
}));
}
Set<File> existingScreenshots = getScreenshots();
runSync(<String>[
'osascript',
'-e',
'activate application "Simulator"\n'
'tell application "System Events" to keystroke "s" using command down'
]);
// There is some latency here from the applescript call.
await new Future<Null>.delayed(new Duration(seconds: 1));
Set<File> shots = getScreenshots().difference(existingScreenshots);
if (shots.isEmpty) {
printError('Unable to locate the screenshot file.');
return false;
}
File shot = shots.first;
outputFile.writeAsBytesSync(shot.readAsBytesSync());
shot.delete();
return true;
}
}
class _IOSSimulatorLogReader extends DeviceLogReader {
String _appName;
_IOSSimulatorLogReader(this.device, ApplicationPackage app) {
_linesController = new StreamController<String>.broadcast(
onListen: () {
_start();
},
onCancel: _stop
);
_appName = app == null ? null : app.name.replaceAll('.app', '');
}
final IOSSimulator device;
StreamController<String> _linesController;
// We log from two files: the device and the system log.
Process _deviceProcess;
Process _systemProcess;
@override
Stream<String> get logLines => _linesController.stream;
@override
String get name => device.name;
Future<Null> _start() async {
// Device log.
device.ensureLogsExists();
_deviceProcess = await runCommand(<String>['tail', '-n', '0', '-F', device.logFilePath]);
_deviceProcess.stdout.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onDeviceLine);
_deviceProcess.stderr.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onDeviceLine);
// Track system.log crashes.
// ReportCrash[37965]: Saved crash report for FlutterRunner[37941]...
_systemProcess = await runCommand(<String>['tail', '-n', '0', '-F', '/private/var/log/system.log']);
_systemProcess.stdout.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onSystemLine);
_systemProcess.stderr.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onSystemLine);
_deviceProcess.exitCode.then((int code) {
if (_linesController.hasListener)
_linesController.close();
});
}
// Match the log prefix (in order to shorten it):
// 'Jan 29 01:31:44 devoncarew-macbookpro3 SpringBoard[96648]: ...'
static final RegExp _mapRegex = new RegExp(r'\S+ +\S+ +\S+ \S+ (.+)\[\d+\]\)?: (.*)$');
// Jan 31 19:23:28 --- last message repeated 1 time ---
static final RegExp _lastMessageSingleRegex = new RegExp(r'\S+ +\S+ +\S+ --- last message repeated 1 time ---$');
static final RegExp _lastMessageMultipleRegex = new RegExp(r'\S+ +\S+ +\S+ --- last message repeated (\d+) times ---$');
static final RegExp _flutterRunnerRegex = new RegExp(r' FlutterRunner\[\d+\] ');
/// List of log categories to always show in the logs, even if this is an
/// app-secific [DeviceLogReader]. Add to this list to make the log output
/// more verbose.
static final List<String> _whitelistedLogCategories = <String>[
'CoreSimulatorBridge',
];
String _filterDeviceLine(String string) {
Match match = _mapRegex.matchAsPrefix(string);
if (match != null) {
String category = match.group(1);
String content = match.group(2);
// Filter out some messages that clearly aren't related to Flutter.
if (string.contains(': could not find icon for representation -> com.apple.'))
return null;
if (category == 'CoreSimulatorBridge'
&& content.startsWith('Pasteboard change listener callback port'))
return null;
if (category == 'routined'
&& content.startsWith('CoreLocation: Error occurred while trying to retrieve motion state update'))
return null;
if (category == 'syslogd' && content == 'ASL Sender Statistics')
return null;
// assertiond: assertion failed: 15E65 13E230: assertiond + 15801 [3C808658-78EC-3950-A264-79A64E0E463B]: 0x1
if (category == 'assertiond'
&& content.startsWith('assertion failed: ')
&& content.endsWith(']: 0x1'))
return null;
if (_appName == null || _whitelistedLogCategories.contains(category))
return '$category: $content';
else if (category == _appName)
return content;
return null;
}
if (_lastMessageSingleRegex.matchAsPrefix(string) != null)
return null;
if (new RegExp(r'assertion failed: .* libxpc.dylib .* 0x7d$').matchAsPrefix(string) != null)
return null;
return string;
}
String _lastLine;
void _onDeviceLine(String line) {
printTrace('[DEVICE LOG] $line');
Match multi = _lastMessageMultipleRegex.matchAsPrefix(line);
if (multi != null) {
if (_lastLine != null) {
int repeat = int.parse(multi.group(1));
repeat = math.max(0, math.min(100, repeat));
for (int i = 1; i < repeat; i++)
_linesController.add(_lastLine);
}
} else {
_lastLine = _filterDeviceLine(line);
if (_lastLine != null)
_linesController.add(_lastLine);
}
}
String _filterSystemLog(String string) {
Match match = _mapRegex.matchAsPrefix(string);
return match == null ? string : '${match.group(1)}: ${match.group(2)}';
}
void _onSystemLine(String line) {
printTrace('[SYS LOG] $line');
if (!_flutterRunnerRegex.hasMatch(line))
return;
String filteredLine = _filterSystemLog(line);
if (filteredLine == null)
return;
_linesController.add(filteredLine);
}
void _stop() {
_deviceProcess?.kill();
_systemProcess?.kill();
}
}
int compareIosVersions(String v1, String v2) {
List<int> v1Fragments = v1.split('.').map(int.parse).toList();
List<int> 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 List<String> qualifiers = const <String>['-Plus', '', 's-Plus', 's'];
int q1 = qualifiers.indexOf(m1[2]);
int q2 = qualifiers.indexOf(m2[2]);
return q1.compareTo(q2);
}
class _IOSSimulatorDevicePortForwarder extends DevicePortForwarder {
_IOSSimulatorDevicePortForwarder(this.device);
final IOSSimulator device;
final List<ForwardedPort> _ports = <ForwardedPort>[];
@override
List<ForwardedPort> get forwardedPorts {
return _ports;
}
@override
Future<int> forward(int devicePort, {int hostPort: null}) async {
if ((hostPort == null) || (hostPort == 0)) {
hostPort = devicePort;
}
assert(devicePort == hostPort);
_ports.add(new ForwardedPort(devicePort, hostPort));
return hostPort;
}
@override
Future<Null> unforward(ForwardedPort forwardedPort) async {
_ports.remove(forwardedPort);
}
}