devices.dart 22.6 KB
Newer Older
1 2 3 4 5 6
// 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';

7 8
import 'package:meta/meta.dart';

9
import '../application_package.dart';
10
import '../artifacts.dart';
11
import '../base/context.dart';
12
import '../base/file_system.dart';
13
import '../base/io.dart';
14
import '../base/logger.dart';
15
import '../base/platform.dart';
16
import '../base/process.dart';
17
import '../base/process_manager.dart';
18
import '../build_info.dart';
19
import '../convert.dart';
20 21
import '../device.dart';
import '../globals.dart';
22
import '../mdns_discovery.dart';
23
import '../project.dart';
24
import '../protocol_discovery.dart';
25
import '../reporting/reporting.dart';
26
import 'code_signing.dart';
27
import 'ios_workflow.dart';
28 29
import 'mac.dart';

30 31 32
class IOSDeploy {
  const IOSDeploy();

33 34
  static IOSDeploy get instance => context.get<IOSDeploy>();

35 36 37 38 39 40 41
  /// Installs and runs the specified app bundle using ios-deploy, then returns
  /// the exit code.
  Future<int> runApp({
    @required String deviceId,
    @required String bundlePath,
    @required List<String> launchArguments,
  }) async {
42 43
    final String iosDeployPath = artifacts.getArtifactPath(Artifact.iosDeploy, platform: TargetPlatform.ios);
    final List<String> launchCommand = <String>[
44
      iosDeployPath,
45 46 47 48 49
      '--id',
      deviceId,
      '--bundle',
      bundlePath,
      '--no-wifi',
50
      '--justlaunch',
51 52 53 54
      if (launchArguments.isNotEmpty) ...<String>[
        '--args',
        '${launchArguments.join(" ")}',
      ],
55 56 57 58 59 60 61 62 63
    ];

    // Push /usr/bin to the front of PATH to pick up default system python, package 'six'.
    //
    // ios-deploy transitively depends on LLDB.framework, which invokes a
    // Python script that uses package 'six'. LLDB.framework relies on the
    // python at the front of the path, which may not include package 'six'.
    // Ensure that we pick up the system install of python, which does include
    // it.
64
    final Map<String, String> iosDeployEnv = Map<String, String>.from(platform.environment);
65
    iosDeployEnv['PATH'] = '/usr/bin:${iosDeployEnv['PATH']}';
66
    iosDeployEnv.addEntries(<MapEntry<String, String>>[cache.dyLdLibEntry]);
67

68
    return await processUtils.stream(
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
      launchCommand,
      mapFunction: _monitorInstallationFailure,
      trace: true,
      environment: iosDeployEnv,
    );
  }

  // Maps stdout line stream. Must return original line.
  String _monitorInstallationFailure(String stdout) {
    // Installation issues.
    if (stdout.contains('Error 0xe8008015') || stdout.contains('Error 0xe8000067')) {
      printError(noProvisioningProfileInstruction, emphasis: true);

    // Launch issues.
    } else if (stdout.contains('e80000e2')) {
      printError('''
═══════════════════════════════════════════════════════════════════════════════════
Your device is locked. Unlock your device first before running.
═══════════════════════════════════════════════════════════════════════════════════''',
      emphasis: true);
    } else if (stdout.contains('Error 0xe8000022')) {
      printError('''
═══════════════════════════════════════════════════════════════════════════════════
Error launching app. Try launching from within Xcode via:
    open ios/Runner.xcworkspace

Your Xcode version may be too old for your iOS version.
═══════════════════════════════════════════════════════════════════════════════════''',
      emphasis: true);
    }

    return stdout;
  }
}

104
class IOSDevices extends PollingDeviceDiscovery {
105
  IOSDevices() : super('iOS devices');
106

107
  @override
108
  bool get supportsPlatform => platform.isMacOS;
109

110
  @override
111
  bool get canListAnything => iosWorkflow.canListDevices;
112

113
  @override
114
  Future<List<Device>> pollingGetDevices() => IOSDevice.getAttachedDevices();
115 116 117
}

class IOSDevice extends Device {
118 119
  IOSDevice(String id, { this.name, String sdkVersion })
      : _sdkVersion = sdkVersion,
120 121 122 123 124 125
        super(
          id,
          category: Category.mobile,
          platformType: PlatformType.ios,
          ephemeral: true,
      ) {
126
    if (!platform.isMacOS) {
127
      assert(false, 'Control of iOS devices or simulators only supported on Mac OS.');
128 129 130 131
      return;
    }
    _installerPath = artifacts.getArtifactPath(
      Artifact.ideviceinstaller,
132
      platform: TargetPlatform.ios,
133
    );
134 135
    _iproxyPath = artifacts.getArtifactPath(
      Artifact.iproxy,
136
      platform: TargetPlatform.ios,
137
    );
138 139 140
  }

  String _installerPath;
141
  String _iproxyPath;
142

143 144
  final String _sdkVersion;

145
  @override
146 147 148 149
  bool get supportsHotReload => true;

  @override
  bool get supportsHotRestart => true;
150

151
  @override
152 153
  final String name;

154
  Map<ApplicationPackage, DeviceLogReader> _logReaders;
155

156
  DevicePortForwarder _portForwarder;
157

158
  @override
159
  Future<bool> get isLocalEmulator async => false;
160

161 162 163
  @override
  Future<String> get emulatorId async => null;

164
  @override
165 166
  bool get supportsStartPaused => false;

167
  static Future<List<IOSDevice>> getAttachedDevices() async {
168 169 170
    if (!platform.isMacOS) {
      throw UnsupportedError('Control of iOS devices or simulators only supported on Mac OS.');
    }
171
    if (!iMobileDevice.isInstalled) {
172
      return <IOSDevice>[];
173
    }
174

175
    final List<IOSDevice> devices = <IOSDevice>[];
176 177
    for (String id in (await iMobileDevice.getAvailableDeviceIDs()).split('\n')) {
      id = id.trim();
178
      if (id.isEmpty) {
179
        continue;
180
      }
181

182 183 184 185 186 187 188
      try {
        final String deviceName = await iMobileDevice.getInfoForDevice(id, 'DeviceName');
        final String sdkVersion = await iMobileDevice.getInfoForDevice(id, 'ProductVersion');
        devices.add(IOSDevice(id, name: deviceName, sdkVersion: sdkVersion));
      } on IOSDeviceNotFoundError catch (error) {
        // Unable to find device with given udid. Possibly a network device.
        printTrace('Error getting attached iOS device: $error');
189 190 191
      } on IOSDeviceNotTrustedError catch (error) {
        printTrace('Error getting attached iOS device information: $error');
        UsageEvent('device', 'ios-trust-failure').send();
192
      }
193 194 195 196 197
    }
    return devices;
  }

  @override
198
  Future<bool> isAppInstalled(ApplicationPackage app) async {
199
    RunResult apps;
200
    try {
201
      apps = await processUtils.run(
202
        <String>[_installerPath, '--list-apps'],
203
        throwOnError: true,
204 205 206 207 208
        environment: Map<String, String>.fromEntries(
          <MapEntry<String, String>>[cache.dyLdLibEntry],
        ),
      );
    } on ProcessException {
209 210
      return false;
    }
211
    return RegExp(app.id, multiLine: true).hasMatch(apps.stdout);
212 213
  }

214
  @override
215
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;
216

217
  @override
218
  Future<bool> installApp(ApplicationPackage app) async {
219 220
    final IOSApp iosApp = app;
    final Directory bundle = fs.directory(iosApp.deviceBundlePath);
221
    if (!bundle.existsSync()) {
222
      printError('Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?');
223 224 225 226
      return false;
    }

    try {
227
      await processUtils.run(
228
        <String>[_installerPath, '-i', iosApp.deviceBundlePath],
229
        throwOnError: true,
230 231 232 233
        environment: Map<String, String>.fromEntries(
          <MapEntry<String, String>>[cache.dyLdLibEntry],
        ),
      );
234
      return true;
235 236
    } on ProcessException catch (error) {
      printError(error.message);
237 238 239
      return false;
    }
  }
240 241

  @override
242
  Future<bool> uninstallApp(ApplicationPackage app) async {
243
    try {
244
      await processUtils.run(
245
        <String>[_installerPath, '-U', app.id],
246
        throwOnError: true,
247 248 249 250
        environment: Map<String, String>.fromEntries(
          <MapEntry<String, String>>[cache.dyLdLibEntry],
        ),
      );
251
      return true;
252 253
    } on ProcessException catch (error) {
      printError(error.message);
254 255 256 257
      return false;
    }
  }

258 259 260
  @override
  bool isSupported() => true;

261
  @override
Devon Carew's avatar
Devon Carew committed
262
  Future<LaunchResult> startApp(
263
    ApplicationPackage package, {
264 265
    String mainPath,
    String route,
Devon Carew's avatar
Devon Carew committed
266
    DebuggingOptions debuggingOptions,
267
    Map<String, dynamic> platformArgs,
268 269
    bool prebuiltApplication = false,
    bool ipv6 = false,
270
  }) async {
271
    if (!prebuiltApplication) {
272
      // TODO(chinmaygarde): Use mainPath, route.
273
      printTrace('Building ${package.name} for $id');
274

275
      final String cpuArchitecture = await iMobileDevice.getInfoForDevice(id, 'CPUArchitecture');
276
      final DarwinArch iosArch = getIOSArchForName(cpuArchitecture);
277

278
      // Step 1: Build the precompiled/DBC application if necessary.
279
      final XcodeBuildResult buildResult = await buildXcodeProject(
280
          app: package,
281
          buildInfo: debuggingOptions.buildInfo,
282
          targetOverride: mainPath,
283
          buildForDevice: true,
284
          activeArch: iosArch,
285
      );
286 287
      if (!buildResult.success) {
        printError('Could not build the precompiled application for the device.');
xster's avatar
xster committed
288
        await diagnoseXcodeBuildFailure(buildResult);
289
        printError('');
290
        return LaunchResult.failed();
291
      }
292
    } else {
293
      if (!await installApp(package)) {
294
        return LaunchResult.failed();
295
      }
296 297 298
    }

    // Step 2: Check that the application exists at the specified path.
299
    final IOSApp iosApp = package;
300
    final Directory bundle = fs.directory(iosApp.deviceBundlePath);
301
    if (!bundle.existsSync()) {
302
      printError('Could not find the built application bundle at ${bundle.path}.');
303
      return LaunchResult.failed();
304 305 306
    }

    // Step 3: Attempt to install the application on the device.
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329
    final List<String> launchArguments = <String>[
      '--enable-dart-profiling',
      if (debuggingOptions.startPaused) '--start-paused',
      if (debuggingOptions.disableServiceAuthCodes) '--disable-service-auth-codes',
      if (debuggingOptions.dartFlags.isNotEmpty) '--dart-flags="${debuggingOptions.dartFlags}"',
      if (debuggingOptions.useTestFonts) '--use-test-fonts',
      // "--enable-checked-mode" and "--verify-entry-points" should always be
      // passed when we launch debug build via "ios-deploy". However, we don't
      // pass them if a certain environment variable is set to enable the
      // "system_debug_ios" integration test in the CI, which simulates a
      // home-screen launch.
      if (debuggingOptions.debuggingEnabled &&
          platform.environment['FLUTTER_TOOLS_DEBUG_WITHOUT_CHECKED_MODE'] != 'true') ...<String>[
        '--enable-checked-mode',
        '--verify-entry-points',
      ],
      if (debuggingOptions.enableSoftwareRendering) '--enable-software-rendering',
      if (debuggingOptions.skiaDeterministicRendering) '--skia-deterministic-rendering',
      if (debuggingOptions.traceSkia) '--trace-skia',
      if (debuggingOptions.dumpSkpOnShaderCompilation) '--dump-skp-on-shader-compilation',
      if (debuggingOptions.verboseSystemLogs) '--verbose-logging',
      if (platformArgs['trace-startup'] ?? false) '--trace-startup',
    ];
330

331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
    final Status installStatus = logger.startProgress(
        'Installing and launching...',
        timeout: timeoutConfiguration.slowOperation);
    try {
      ProtocolDiscovery observatoryDiscovery;
      if (debuggingOptions.debuggingEnabled) {
        // Debugging is enabled, look for the observatory server port post launch.
        printTrace('Debugging is enabled, connecting to observatory');

        // TODO(danrubel): The Android device class does something similar to this code below.
        // The various Device subclasses should be refactored and common code moved into the superclass.
        observatoryDiscovery = ProtocolDiscovery.observatory(
          getLogReader(app: package),
          portForwarder: portForwarder,
          hostPort: debuggingOptions.observatoryPort,
          ipv6: ipv6,
        );
      }
349
      final int installationResult = await IOSDeploy.instance.runApp(
350 351 352
        deviceId: id,
        bundlePath: bundle.path,
        launchArguments: launchArguments,
353
      );
354 355 356 357 358 359 360
      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.xcworkspace');
        printError('');
        return LaunchResult.failed();
      }
361

362 363 364
      if (!debuggingOptions.debuggingEnabled) {
        return LaunchResult.succeeded();
      }
365

366
      Uri localUri;
367
      try {
368
        printTrace('Application launched on the device. Waiting for observatory port.');
369 370 371 372 373 374 375
        localUri = await MDnsObservatoryDiscovery.instance.getObservatoryUri(
          package.id,
          this,
          ipv6,
          debuggingOptions.observatoryPort,
        );
        if (localUri != null) {
376
          UsageEvent('ios-mdns', 'success').send();
377 378 379
          return LaunchResult.succeeded(observatoryUri: localUri);
        }
      } catch (error) {
380
        printError('Failed to establish a debug connection with $id using mdns: $error');
381 382
      }

383 384
      // Fallback to manual protocol discovery.
      UsageEvent('ios-mdns', 'failure').send();
385 386 387 388 389
      printTrace('mDNS lookup failed, attempting fallback to reading device log.');
      try {
        printTrace('Waiting for observatory port.');
        localUri = await observatoryDiscovery.uri;
        if (localUri != null) {
390
          UsageEvent('ios-mdns', 'fallback-success').send();
391 392
          return LaunchResult.succeeded(observatoryUri: localUri);
        }
393
      } catch (error) {
394
        printError('Failed to establish a debug connection with $id using logs: $error');
395 396 397
      } finally {
        await observatoryDiscovery?.cancel();
      }
398
      UsageEvent('ios-mdns', 'fallback-failure').send();
399
      return LaunchResult.failed();
400 401
    } finally {
      installStatus.stop();
402
    }
403 404
  }

405 406 407 408 409 410 411
  @override
  Future<bool> stopApp(ApplicationPackage app) async {
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

  @override
412
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
413

414
  @override
415
  Future<String> get sdkNameAndVersion async => 'iOS $_sdkVersion';
416

417
  @override
418
  DeviceLogReader getLogReader({ ApplicationPackage app }) {
419
    _logReaders ??= <ApplicationPackage, DeviceLogReader>{};
420
    return _logReaders.putIfAbsent(app, () => _IOSDeviceLogReader(this, app));
421 422
  }

423 424 425 426 427 428
  @visibleForTesting
  void setLogReader(ApplicationPackage app, DeviceLogReader logReader) {
    _logReaders ??= <ApplicationPackage, DeviceLogReader>{};
    _logReaders[app] = logReader;
  }

429
  @override
430
  DevicePortForwarder get portForwarder => _portForwarder ??= _IOSDevicePortForwarder(this);
431

432 433 434 435 436
  @visibleForTesting
  set portForwarder(DevicePortForwarder forwarder) {
    _portForwarder = forwarder;
  }

437
  @override
438
  void clearLogs() { }
Devon Carew's avatar
Devon Carew committed
439 440

  @override
441
  bool get supportsScreenshot => iMobileDevice.isInstalled;
Devon Carew's avatar
Devon Carew committed
442 443

  @override
444
  Future<void> takeScreenshot(File outputFile) async {
445 446
    await iMobileDevice.takeScreenshot(outputFile);
  }
447 448 449 450 451

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.ios.existsSync();
  }
452 453
}

454
/// Decodes a vis-encoded syslog string to a UTF-8 representation.
455 456 457 458 459 460 461 462 463
///
/// Apple's syslog logs are encoded in 7-bit form. Input bytes are encoded as follows:
/// 1. 0x00 to 0x19: non-printing range. Some ignored, some encoded as <...>.
/// 2. 0x20 to 0x7f: as-is, with the exception of 0x5c (backslash).
/// 3. 0x5c (backslash): octal representation \134.
/// 4. 0x80 to 0x9f: \M^x (using control-character notation for range 0x00 to 0x40).
/// 5. 0xa0: octal representation \240.
/// 6. 0xa1 to 0xf7: \M-x (where x is the input byte stripped of its high-order bit).
/// 7. 0xf8 to 0xff: unused in 4-byte UTF-8.
464 465
///
/// See: [vis(3) manpage](https://www.freebsd.org/cgi/man.cgi?query=vis&sektion=3)
466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482
String decodeSyslog(String line) {
  // UTF-8 values for \, M, -, ^.
  const int kBackslash = 0x5c;
  const int kM = 0x4d;
  const int kDash = 0x2d;
  const int kCaret = 0x5e;

  // Mask for the UTF-8 digit range.
  const int kNum = 0x30;

  // Returns true when `byte` is within the UTF-8 7-bit digit range (0x30 to 0x39).
  bool isDigit(int byte) => (byte & 0xf0) == kNum;

  // Converts a three-digit ASCII (UTF-8) representation of an octal number `xyz` to an integer.
  int decodeOctal(int x, int y, int z) => (x & 0x3) << 6 | (y & 0x7) << 3 | z & 0x7;

  try {
483
    final List<int> bytes = utf8.encode(line);
484
    final List<int> out = <int>[];
485
    for (int i = 0; i < bytes.length;) {
486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506
      if (bytes[i] != kBackslash || i > bytes.length - 4) {
        // Unmapped byte: copy as-is.
        out.add(bytes[i++]);
      } else {
        // Mapped byte: decode next 4 bytes.
        if (bytes[i + 1] == kM && bytes[i + 2] == kCaret) {
          // \M^x form: bytes in range 0x80 to 0x9f.
          out.add((bytes[i + 3] & 0x7f) + 0x40);
        } else if (bytes[i + 1] == kM && bytes[i + 2] == kDash) {
          // \M-x form: bytes in range 0xa0 to 0xf7.
          out.add(bytes[i + 3] | 0x80);
        } else if (bytes.getRange(i + 1, i + 3).every(isDigit)) {
          // \ddd form: octal representation (only used for \134 and \240).
          out.add(decodeOctal(bytes[i + 1], bytes[i + 2], bytes[i + 3]));
        } else {
          // Unknown form: copy as-is.
          out.addAll(bytes.getRange(0, 4));
        }
        i += 4;
      }
    }
507
    return utf8.decode(out);
508 509 510 511 512 513
  } catch (_) {
    // Unable to decode line: return as-is.
    return line;
  }
}

514
class _IOSDeviceLogReader extends DeviceLogReader {
515
  _IOSDeviceLogReader(this.device, ApplicationPackage app) {
516
    _linesController = StreamController<String>.broadcast(
517
      onListen: _start,
518
      onCancel: _stop,
519 520 521 522 523
    );

    // Match for lines for the runner in syslog.
    //
    // iOS 9 format:  Runner[297] <Notice>:
524
    // iOS 10 format: Runner(Flutter)[297] <Notice>:
525
    final String appName = app == null ? '' : app.name.replaceAll('.app', '');
526
    _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: ');
527 528 529
    // Similar to above, but allows ~arbitrary components instead of "Runner"
    // and "Flutter". The regex tries to strike a balance between not producing
    // false positives and not producing false negatives.
530
    _anyLineRegex = RegExp(r'\w+(\([^)]*\))?\[\d+\] <[A-Za-z]+>: ');
Devon Carew's avatar
Devon Carew committed
531
  }
532 533 534

  final IOSDevice device;

535 536 537 538 539
  // Matches a syslog line from the runner.
  RegExp _runnerLineRegex;
  // Matches a syslog line from any app.
  RegExp _anyLineRegex;

Devon Carew's avatar
Devon Carew committed
540
  StreamController<String> _linesController;
541
  Process _process;
542

543
  @override
Devon Carew's avatar
Devon Carew committed
544
  Stream<String> get logLines => _linesController.stream;
545

546
  @override
547 548
  String get name => device.name;

Devon Carew's avatar
Devon Carew committed
549
  void _start() {
550
    iMobileDevice.startLogger(device.id).then<void>((Process process) {
Devon Carew's avatar
Devon Carew committed
551
      _process = process;
552 553
      _process.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newLineHandler());
      _process.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newLineHandler());
554
      _process.exitCode.whenComplete(() {
555
        if (_linesController.hasListener) {
Devon Carew's avatar
Devon Carew committed
556
          _linesController.close();
557
        }
Devon Carew's avatar
Devon Carew committed
558 559
      });
    });
560 561
  }

562 563 564 565 566 567 568 569 570 571 572 573 574 575 576
  // Returns a stateful line handler to properly capture multi-line output.
  //
  // For multi-line log messages, any line after the first is logged without
  // any specific prefix. To properly capture those, we enter "printing" mode
  // after matching a log line from the runner. When in printing mode, we print
  // all lines until we find the start of another log message (from any app).
  Function _newLineHandler() {
    bool printing = false;

    return (String line) {
      if (printing) {
        if (!_anyLineRegex.hasMatch(line)) {
          _linesController.add(decodeSyslog(line));
          return;
        }
577

578 579 580 581 582 583 584 585 586 587 588 589 590
        printing = false;
      }

      final Match match = _runnerLineRegex.firstMatch(line);

      if (match != null) {
        final String logLine = line.substring(match.end);
        // Only display the log line after the initial device and executable information.
        _linesController.add(decodeSyslog(logLine));

        printing = true;
      }
    };
591 592
  }

Devon Carew's avatar
Devon Carew committed
593 594
  void _stop() {
    _process?.kill();
595 596
  }
}
597 598

class _IOSDevicePortForwarder extends DevicePortForwarder {
599
  _IOSDevicePortForwarder(this.device) : _forwardedPorts = <ForwardedPort>[];
600 601 602

  final IOSDevice device;

603 604
  final List<ForwardedPort> _forwardedPorts;

605
  @override
606
  List<ForwardedPort> get forwardedPorts => _forwardedPorts;
607

608
  static const Duration _kiProxyPortForwardTimeout = Duration(seconds: 1);
609

610
  @override
611
  Future<int> forward(int devicePort, { int hostPort }) async {
612
    final bool autoselect = hostPort == null || hostPort == 0;
613
    if (autoselect) {
614
      hostPort = 1024;
615
    }
616 617 618 619 620 621 622

    Process process;

    bool connected = false;
    while (!connected) {
      printTrace('attempting to forward device port $devicePort to host port $hostPort');
      // Usage: iproxy LOCAL_TCP_PORT DEVICE_TCP_PORT UDID
623
      process = await processUtils.start(
624 625 626 627 628 629 630 631 632 633
        <String>[
          device._iproxyPath,
          hostPort.toString(),
          devicePort.toString(),
          device.id,
        ],
        environment: Map<String, String>.fromEntries(
          <MapEntry<String, String>>[cache.dyLdLibEntry],
        ),
      );
634 635 636 637 638
      // TODO(ianh): This is a flakey race condition, https://github.com/libimobiledevice/libimobiledevice/issues/674
      connected = !await process.stdout.isEmpty.timeout(_kiProxyPortForwardTimeout, onTimeout: () => false);
      if (!connected) {
        if (autoselect) {
          hostPort += 1;
639
          if (hostPort > 65535) {
640
            throw Exception('Could not find open port on host.');
641
          }
642
        } else {
643
          throw Exception('Port $hostPort is not available.');
644 645
        }
      }
646
    }
647 648
    assert(connected);
    assert(process != null);
649

650
    final ForwardedPort forwardedPort = ForwardedPort.withContext(
651 652
      hostPort, devicePort, process,
    );
653
    printTrace('Forwarded port $forwardedPort');
654
    _forwardedPorts.add(forwardedPort);
655
    return hostPort;
656 657
  }

658
  @override
659
  Future<void> unforward(ForwardedPort forwardedPort) async {
660 661
    if (!_forwardedPorts.remove(forwardedPort)) {
      // Not in list. Nothing to remove.
662
      return;
663 664
    }

665
    printTrace('Unforwarding port $forwardedPort');
666

667
    final Process process = forwardedPort.context;
668 669

    if (process != null) {
670
      processManager.killPid(process.pid);
671
    } else {
672
      printError('Forwarded port did not have a valid process');
673
    }
674 675
  }
}