fuchsia_device.dart 28.4 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
// @dart = 2.8

7 8
import 'dart:async';

9 10
import 'package:meta/meta.dart';

11
import '../application_package.dart';
12
import '../artifacts.dart';
13
import '../base/common.dart';
14 15
import '../base/context.dart';
import '../base/file_system.dart';
16
import '../base/io.dart';
17
import '../base/logger.dart';
18
import '../base/net.dart';
19
import '../base/platform.dart';
20
import '../base/process.dart';
21
import '../base/time.dart';
22 23
import '../build_info.dart';
import '../device.dart';
24
import '../device_port_forwarder.dart';
25
import '../globals.dart' as globals;
26
import '../project.dart';
27
import '../vmservice.dart';
28

29 30 31 32
import 'amber_ctl.dart';
import 'application_package.dart';
import 'fuchsia_build.dart';
import 'fuchsia_pm.dart';
33 34
import 'fuchsia_sdk.dart';
import 'fuchsia_workflow.dart';
35 36 37 38 39 40 41 42 43 44 45 46 47 48
import 'tiles_ctl.dart';

/// The [FuchsiaDeviceTools] instance.
FuchsiaDeviceTools get fuchsiaDeviceTools => context.get<FuchsiaDeviceTools>();

/// Fuchsia device-side tools.
class FuchsiaDeviceTools {
  FuchsiaAmberCtl _amberCtl;
  FuchsiaAmberCtl get amberCtl => _amberCtl ??= FuchsiaAmberCtl();

  FuchsiaTilesCtl _tilesCtl;
  FuchsiaTilesCtl get tilesCtl => _tilesCtl ??= FuchsiaTilesCtl();
}

49 50 51
final String _ipv4Loopback = InternetAddress.loopbackIPv4.address;
final String _ipv6Loopback = InternetAddress.loopbackIPv6.address;

52
// Enables testing the fuchsia isolate discovery
53
Future<FlutterVmService> _kDefaultFuchsiaIsolateDiscoveryConnector(Uri uri) {
54
  return connectToVmService(uri, logger: globals.logger);
55 56
}

57 58 59
Future<void> _kDefaultDartDevelopmentServiceStarter(
  Device device,
  Uri observatoryUri,
60
  bool disableServiceAuthCodes,
61
) async {
62 63 64 65 66
  await device.dds.startDartDevelopmentService(
    observatoryUri,
    0,
    true,
    disableServiceAuthCodes,
67
    logger: globals.logger,
68
  );
69 70
}

71 72
/// Read the log for a particular device.
class _FuchsiaLogReader extends DeviceLogReader {
73
  _FuchsiaLogReader(this._device, this._systemClock, [this._app]);
74

75 76
  // \S matches non-whitespace characters.
  static final RegExp _flutterLogOutput = RegExp(r'INFO: \S+\(flutter\): ');
77

78 79
  final FuchsiaDevice _device;
  final ApplicationPackage _app;
80
  final SystemClock _systemClock;
81

82 83
  @override
  String get name => _device.name;
84 85 86 87

  Stream<String> _logLines;
  @override
  Stream<String> get logLines {
88 89
    final Stream<String> logStream = fuchsiaSdk.syslogs(_device.id);
    _logLines ??= _processLogs(logStream);
90 91 92
    return _logLines;
  }

93
  Stream<String> _processLogs(Stream<String> lines) {
94 95 96
    if (lines == null) {
      return null;
    }
97 98
    // Get the starting time of the log processor to filter logs from before
    // the process attached.
99
    final DateTime startTime = _systemClock.now();
100 101 102
    // Determine if line comes from flutter, and optionally whether it matches
    // the correct fuchsia module.
    final RegExp matchRegExp = _app == null
103
        ? _flutterLogOutput
104
        : RegExp('INFO: ${_app.name}(\\.cmx)?\\(flutter\\): ');
105 106
    return Stream<String>.eventTransformed(
      lines,
107
      (EventSink<String> output) => _FuchsiaLogSink(output, matchRegExp, startTime),
108
    );
109 110
  }

111 112
  @override
  String toString() => name;
113 114 115 116 117 118

  @override
  void dispose() {
    // The Fuchsia SDK syslog process is killed when the subscription to the
    // logLines Stream is canceled.
  }
119 120
}

121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
class _FuchsiaLogSink implements EventSink<String> {
  _FuchsiaLogSink(this._outputSink, this._matchRegExp, this._startTime);

  static final RegExp _utcDateOutput = RegExp(r'\d+\-\d+\-\d+ \d+:\d+:\d+');
  final EventSink<String> _outputSink;
  final RegExp _matchRegExp;
  final DateTime _startTime;

  @override
  void add(String line) {
    if (!_matchRegExp.hasMatch(line)) {
      return;
    }
    final String rawDate = _utcDateOutput.firstMatch(line)?.group(0);
    if (rawDate == null) {
      return;
    }
    final DateTime logTime = DateTime.parse(rawDate);
    if (logTime.millisecondsSinceEpoch < _startTime.millisecondsSinceEpoch) {
      return;
    }
142 143
    _outputSink.add(
        '[${logTime.toLocal()}] Flutter: ${line.split(_matchRegExp).last}');
144 145 146
  }

  @override
147
  void addError(Object error, [StackTrace stackTrace]) {
148 149 150 151
    _outputSink.addError(error, stackTrace);
  }

  @override
152 153 154
  void close() {
    _outputSink.close();
  }
155 156
}

157
/// Device discovery for Fuchsia devices.
158
class FuchsiaDevices extends PollingDeviceDiscovery {
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
  FuchsiaDevices({
    @required Platform platform,
    @required FuchsiaWorkflow fuchsiaWorkflow,
    @required FuchsiaSdk fuchsiaSdk,
    @required Logger logger,
  }) : _platform = platform,
       _fuchsiaWorkflow = fuchsiaWorkflow,
       _fuchsiaSdk = fuchsiaSdk,
       _logger = logger,
       super('Fuchsia devices');

  final Platform _platform;
  final FuchsiaWorkflow _fuchsiaWorkflow;
  final FuchsiaSdk _fuchsiaSdk;
  final Logger _logger;
174 175

  @override
176
  bool get supportsPlatform => isFuchsiaSupportedPlatform(_platform);
177 178

  @override
179
  bool get canListAnything => _fuchsiaWorkflow.canListDevices;
180 181

  @override
182
  Future<List<Device>> pollingGetDevices({ Duration timeout }) async {
183
    if (!_fuchsiaWorkflow.canListDevices) {
184 185
      return <Device>[];
    }
186 187 188 189 190
    // TODO(omerlevran): Remove once soft transition is complete fxb/67602.
    final List<String> text = (await _fuchsiaSdk.listDevices(
      timeout: timeout,
      useDeviceFinder: _fuchsiaWorkflow.shouldUseDeviceFinder,
    ))?.split('\n');
191
    if (text == null || text.isEmpty) {
192 193
      return <Device>[];
    }
194 195 196 197 198 199 200 201
    final List<FuchsiaDevice> devices = <FuchsiaDevice>[];
    for (final String line in text) {
      final FuchsiaDevice device = await _parseDevice(line);
      if (device == null) {
        continue;
      }
      devices.add(device);
    }
202
    return devices;
203 204 205 206 207
  }

  @override
  Future<List<String>> getDiagnostics() async => const <String>[];

208 209
  Future<FuchsiaDevice> _parseDevice(String text) async {
    final String line = text.trim();
210
    // ['ip', 'device name']
211
    final List<String> words = line.split(' ');
212
    if (words.length < 2) {
213
      return null;
214
    }
215
    final String name = words[1];
216 217 218 219 220 221 222 223 224 225 226 227
    String resolvedHost;

    // TODO(omerlevran): Remove once soft transition is complete fxb/67602.
    if (_fuchsiaWorkflow.shouldUseDeviceFinder) {
      // TODO(omerlevran): Add support for resolve on the FuchsiaSdk Object.
      resolvedHost = await _fuchsiaSdk.fuchsiaDevFinder.resolve(
        name,
      );
    } else {
      // TODO(omerlevran): Add support for resolve on the FuchsiaSdk Object.
      resolvedHost = await _fuchsiaSdk.fuchsiaFfx.resolve(name);
    }
228
    if (resolvedHost == null) {
229 230
      _logger.printError('Failed to resolve host for Fuchsia device `$name`');
      return null;
231
    }
232
    return FuchsiaDevice(resolvedHost, name: name);
233
  }
234 235 236

  @override
  List<String> get wellKnownIds => const <String>[];
237 238
}

239

240
class FuchsiaDevice extends Device {
241 242 243 244
  FuchsiaDevice(String id, {this.name}) : super(
      id,
      platformType: PlatformType.fuchsia,
      category: null,
245
      ephemeral: true,
246
  );
247 248

  @override
249 250 251 252
  bool get supportsHotReload => true;

  @override
  bool get supportsHotRestart => false;
253

254
  @override
255
  bool get supportsFlutterExit => false;
256

257 258 259 260
  @override
  final String name;

  @override
261
  Future<bool> get isLocalEmulator async => false;
262

263 264 265
  @override
  Future<String> get emulatorId async => null;

266 267 268 269
  @override
  bool get supportsStartPaused => false;

  @override
270 271 272 273
  Future<bool> isAppInstalled(
    ApplicationPackage app, {
    String userIdentifier,
  }) async => false;
274 275

  @override
276
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;
277 278

  @override
279 280 281 282
  Future<bool> installApp(
    ApplicationPackage app, {
    String userIdentifier,
  }) => Future<bool>.value(false);
283 284

  @override
285 286 287 288
  Future<bool> uninstallApp(
    ApplicationPackage app, {
    String userIdentifier,
  }) async => false;
289 290 291 292

  @override
  bool isSupported() => true;

293 294 295
  @override
  bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease;

296 297
  @override
  Future<LaunchResult> startApp(
298
    covariant FuchsiaApp package, {
299 300 301 302
    String mainPath,
    String route,
    DebuggingOptions debuggingOptions,
    Map<String, dynamic> platformArgs,
303 304
    bool prebuiltApplication = false,
    bool ipv6 = false,
305
    String userIdentifier,
306 307 308
  }) async {
    if (!prebuiltApplication) {
      await buildFuchsia(fuchsiaProject: FlutterProject.current().fuchsia,
309
                         targetPlatform: await targetPlatform,
310 311 312 313 314
                         target: mainPath,
                         buildInfo: debuggingOptions.buildInfo);
    }
    // Stop the app if it's currently running.
    await stopApp(package);
315
    final String host = await hostAddress;
316
    // Find out who the device thinks we are.
317
    final int port = await globals.os.findFreePort();
318
    if (port == 0) {
319
      globals.printError('Failed to find a free port');
320 321
      return LaunchResult.failed();
    }
322 323 324

    // Try Start with a fresh package repo in case one was left over from a
    // previous run.
325
    final Directory packageRepo =
326
        globals.fs.directory(globals.fs.path.join(getFuchsiaBuildDirectory(), '.pkg-repo'));
327 328 329 330 331
    try {
      if (packageRepo.existsSync()) {
        packageRepo.deleteSync(recursive: true);
      }
      packageRepo.createSync(recursive: true);
332
    } on Exception catch (e) {
333
      globals.printError('Failed to create Fuchsia package repo directory '
334 335 336
                 'at ${packageRepo.path}: $e');
      return LaunchResult.failed();
    }
337 338

    final String appName = FlutterProject.current().manifest.appName;
339
    final Status status = globals.logger.startProgress(
340
      'Starting Fuchsia application $appName...',
341 342 343 344
    );
    FuchsiaPackageServer fuchsiaPackageServer;
    bool serverRegistered = false;
    try {
345 346 347 348
      // Ask amber to pre-fetch some things we'll need before setting up our own
      // package server. This is to avoid relying on amber correctly using
      // multiple package servers, support for which is in flux.
      if (!await fuchsiaDeviceTools.amberCtl.getUp(this, 'tiles')) {
349
        globals.printError('Failed to get amber to prefetch tiles');
350 351 352
        return LaunchResult.failed();
      }
      if (!await fuchsiaDeviceTools.amberCtl.getUp(this, 'tiles_ctl')) {
353
        globals.printError('Failed to get amber to prefetch tiles_ctl');
354 355 356
        return LaunchResult.failed();
      }

357
      // Start up a package server.
358
      const String packageServerName = FuchsiaPackageServer.toolHost;
359 360
      fuchsiaPackageServer = FuchsiaPackageServer(
          packageRepo.path, packageServerName, host, port);
361
      if (!await fuchsiaPackageServer.start()) {
362
        globals.printError('Failed to start the Fuchsia package server');
363 364
        return LaunchResult.failed();
      }
365 366

      // Serve the application's package.
367 368 369
      final File farArchive = package.farArchive(
          debuggingOptions.buildInfo.mode);
      if (!await fuchsiaPackageServer.addPackage(farArchive)) {
370
        globals.printError('Failed to add package to the package server');
371 372 373
        return LaunchResult.failed();
      }

374
      // Serve the flutter_runner.
375
      final File flutterRunnerArchive = globals.fs.file(globals.artifacts.getArtifactPath(
376
        Artifact.fuchsiaFlutterRunner,
377 378 379 380
        platform: await targetPlatform,
        mode: debuggingOptions.buildInfo.mode,
      ));
      if (!await fuchsiaPackageServer.addPackage(flutterRunnerArchive)) {
381
        globals.printError('Failed to add flutter_runner package to the package server');
382 383 384
        return LaunchResult.failed();
      }

385 386
      // Teach the package controller about the package server.
      if (!await fuchsiaDeviceTools.amberCtl.addRepoCfg(this, fuchsiaPackageServer)) {
387
        globals.printError('Failed to teach amber about the package server');
388 389 390 391
        return LaunchResult.failed();
      }
      serverRegistered = true;

392
      // Tell the package controller to prefetch the flutter_runner.
393 394 395 396 397 398 399 400 401 402 403 404 405
      String flutterRunnerName;
      if (debuggingOptions.buildInfo.usesAot) {
        if (debuggingOptions.buildInfo.mode.isRelease) {
          flutterRunnerName = 'flutter_aot_product_runner';
        } else {
          flutterRunnerName = 'flutter_aot_runner';
        }
      } else {
        if (debuggingOptions.buildInfo.mode.isRelease) {
          flutterRunnerName = 'flutter_jit_product_runner';
        } else {
          flutterRunnerName = 'flutter_jit_runner';
        }
406 407 408
      }
      if (!await fuchsiaDeviceTools.amberCtl.pkgCtlResolve(
          this, fuchsiaPackageServer, flutterRunnerName)) {
409
        globals.printError('Failed to get pkgctl to prefetch the flutter_runner');
410 411 412
        return LaunchResult.failed();
      }

413 414 415
      // Tell the package controller to prefetch the app.
      if (!await fuchsiaDeviceTools.amberCtl.pkgCtlResolve(
          this, fuchsiaPackageServer, appName)) {
416
        globals.printError('Failed to get pkgctl to prefetch the package');
417 418 419 420 421
        return LaunchResult.failed();
      }

      // Ensure tiles_ctl is started, and start the app.
      if (!await FuchsiaTilesCtl.ensureStarted(this)) {
422
        globals.printError('Failed to ensure that tiles is started on the device');
423 424 425 426
        return LaunchResult.failed();
      }

      // Instruct tiles_ctl to start the app.
427
      final String fuchsiaUrl = 'fuchsia-pkg://$packageServerName/$appName#meta/$appName.cmx';
428
      if (!await fuchsiaDeviceTools.tilesCtl.add(this, fuchsiaUrl, <String>[])) {
429
        globals.printError('Failed to add the app to tiles');
430 431 432
        return LaunchResult.failed();
      }
    } finally {
433 434
      // Try to un-teach the package controller about the package server if
      // needed.
435
      if (serverRegistered) {
436
        await fuchsiaDeviceTools.amberCtl.pkgCtlRepoRemove(this, fuchsiaPackageServer);
437 438
      }
      // Shutdown the package server and delete the package repo;
439
      globals.printTrace("Shutting down the tool's package server.");
440
      fuchsiaPackageServer?.stop();
441
      globals.printTrace("Removing the tool's package repo: at ${packageRepo.path}");
442 443
      try {
        packageRepo.deleteSync(recursive: true);
444
      } on Exception catch (e) {
445
        globals.printError('Failed to remove Fuchsia package repo directory '
446 447
                   'at ${packageRepo.path}: $e.');
      }
448 449 450
      status.cancel();
    }

451
    if (debuggingOptions.buildInfo.mode.isRelease) {
452
      globals.printTrace('App successfully started in a release mode.');
453 454
      return LaunchResult.succeeded();
    }
455
    globals.printTrace('App started in a non-release mode. Setting up vmservice connection.');
456 457 458

    // In a debug or profile build, try to find the observatory uri.
    final FuchsiaIsolateDiscoveryProtocol discovery =
459
      getIsolateDiscoveryProtocol(appName);
460 461 462 463 464 465 466
    try {
      final Uri observatoryUri = await discovery.uri;
      return LaunchResult.succeeded(observatoryUri: observatoryUri);
    } finally {
      discovery.dispose();
    }
  }
467 468

  @override
469 470 471 472
  Future<bool> stopApp(
    covariant FuchsiaApp app, {
    String userIdentifier,
  }) async {
473 474
    final int appKey = await FuchsiaTilesCtl.findAppKey(this, app.id);
    if (appKey != -1) {
475
      if (!await fuchsiaDeviceTools.tilesCtl.remove(this, appKey)) {
476
        globals.printError('tiles_ctl remove on ${app.id} failed.');
477 478 479 480
        return false;
      }
    }
    return true;
481 482
  }

483 484 485
  TargetPlatform _targetPlatform;

  Future<TargetPlatform> _queryTargetPlatform() async {
486 487 488 489 490 491 492
    const TargetPlatform defaultTargetPlatform = TargetPlatform.fuchsia_arm64;
    if (!globals.fuchsiaArtifacts.hasSshConfig) {
      globals.printTrace('Could not determine Fuchsia target platform because '
                 'Fuchsia ssh configuration is missing.\n'
                 'Defaulting to arm64.');
      return defaultTargetPlatform;
    }
493 494
    final RunResult result = await shell('uname -m');
    if (result.exitCode != 0) {
495
      globals.printError('Could not determine Fuchsia target platform type:\n$result\n'
496
                 'Defaulting to arm64.');
497
      return defaultTargetPlatform;
498 499 500 501 502 503 504 505
    }
    final String machine = result.stdout.trim();
    switch (machine) {
      case 'aarch64':
        return TargetPlatform.fuchsia_arm64;
      case 'x86_64':
        return TargetPlatform.fuchsia_x64;
      default:
506
        globals.printError('Unknown Fuchsia target platform "$machine". '
507
                   'Defaulting to arm64.');
508
        return defaultTargetPlatform;
509 510 511
    }
  }

512
  @override
513
  bool get supportsScreenshot => isFuchsiaSupportedPlatform(globals.platform);
514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536

  @override
  Future<void> takeScreenshot(File outputFile) async {
    if (outputFile.basename.split('.').last != 'ppm') {
      throw '${outputFile.path} must be a .ppm file';
    }
    final RunResult screencapResult = await shell('screencap > /tmp/screenshot.ppm');
    if (screencapResult.exitCode != 0) {
      throw 'Could not take a screenshot on device $name:\n$screencapResult';
    }
    try {
      final RunResult scpResult =  await scp('/tmp/screenshot.ppm', outputFile.path);
      if (scpResult.exitCode != 0) {
        throw 'Failed to copy screenshot from device:\n$scpResult';
      }
    } finally {
      try {
        final RunResult deleteResult = await shell('rm /tmp/screenshot.ppm');
        if (deleteResult.exitCode != 0) {
          globals.printError(
            'Failed to delete screenshot.ppm from the device:\n$deleteResult'
          );
        }
537
      } on Exception catch (e) {
538
        globals.printError(
539
          'Failed to delete screenshot.ppm from the device: $e'
540 541 542 543 544
        );
      }
    }
  }

545
  @override
546
  Future<TargetPlatform> get targetPlatform async => _targetPlatform ??= await _queryTargetPlatform();
547 548

  @override
549
  Future<String> get sdkNameAndVersion async {
550 551 552 553 554 555
    const String defaultName = 'Fuchsia';
    if (!globals.fuchsiaArtifacts.hasSshConfig) {
      globals.printTrace('Could not determine Fuchsia sdk name or version '
                 'because Fuchsia ssh configuration is missing.');
      return defaultName;
    }
556 557 558
    const String versionPath = '/pkgfs/packages/build-info/0/data/version';
    final RunResult catResult = await shell('cat $versionPath');
    if (catResult.exitCode != 0) {
559
      globals.printTrace('Failed to cat $versionPath: ${catResult.stderr}');
560
      return defaultName;
561 562 563
    }
    final String version = catResult.stdout.trim();
    if (version.isEmpty) {
564
      globals.printTrace('$versionPath was empty');
565
      return defaultName;
566 567 568
    }
    return 'Fuchsia $version';
  }
569 570

  @override
571 572 573 574 575
  DeviceLogReader getLogReader({
    ApplicationPackage app,
    bool includePastLogs = false,
  }) {
    assert(!includePastLogs, 'Past log reading not supported on Fuchsia.');
576
    return _logReader ??= _FuchsiaLogReader(this, globals.systemClock, app);
577
  }
578
  _FuchsiaLogReader _logReader;
579 580

  @override
581 582
  DevicePortForwarder get portForwarder =>
      _portForwarder ??= _FuchsiaPortForwarder(this);
583 584 585 586 587 588
  DevicePortForwarder _portForwarder;

  @visibleForTesting
  set portForwarder(DevicePortForwarder forwarder) {
    _portForwarder = forwarder;
  }
589 590

  @override
591
  void clearLogs() {}
592

593 594 595 596
  bool _ipv6;

  /// [true] if the current host address is IPv6.
  bool get ipv6 => _ipv6 ??= isIPv6Address(id);
597 598 599 600 601 602 603

  /// Return the address that the device should use to communicate with the
  /// host.
  Future<String> get hostAddress async {
    if (_cachedHostAddress != null) {
      return _cachedHostAddress;
    }
604
    final RunResult result = await shell(r'echo $SSH_CONNECTION');
605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622
    void fail() {
      throwToolExit('Failed to get local address, aborting.\n$result');
    }
    if (result.exitCode != 0) {
      fail();
    }
    final List<String> splitResult = result.stdout.split(' ');
    if (splitResult.isEmpty) {
      fail();
    }
    final String addr = splitResult[0].replaceAll('%', '%25');
    if (addr.isEmpty) {
      fail();
    }
    return _cachedHostAddress = addr;
  }

  String _cachedHostAddress;
623

624 625
  /// List the ports currently running a dart observatory.
  Future<List<int>> servicePorts() async {
626 627 628
    const String findCommand = 'find /hub -name vmservice-port';
    final RunResult findResult = await shell(findCommand);
    if (findResult.exitCode != 0) {
629
      throwToolExit("'$findCommand' on device $name failed. stderr: '${findResult.stderr}'");
630 631
    }
    final String findOutput = findResult.stdout;
632
    if (findOutput.trim() == '') {
633 634
      throwToolExit(
          'No Dart Observatories found. Are you running a debug build?');
635 636
    }
    final List<int> ports = <int>[];
637
    for (final String path in findOutput.split('\n')) {
638 639 640
      if (path == '') {
        continue;
      }
641 642 643
      final String lsCommand = 'ls $path';
      final RunResult lsResult = await shell(lsCommand);
      if (lsResult.exitCode != 0) {
644
        throwToolExit("'$lsCommand' on device $name failed");
645 646
      }
      final String lsOutput = lsResult.stdout;
647
      for (final String line in lsOutput.split('\n')) {
648 649 650 651 652 653 654 655 656 657
        if (line == '') {
          continue;
        }
        final int port = int.tryParse(line);
        if (port != null) {
          ports.add(port);
        }
      }
    }
    return ports;
658 659 660
  }

  /// Run `command` on the Fuchsia device shell.
661
  Future<RunResult> shell(String command) async {
662
    if (globals.fuchsiaArtifacts.sshConfig == null) {
663 664 665
      throwToolExit('Cannot interact with device. No ssh config.\n'
                    'Try setting FUCHSIA_SSH_CONFIG or FUCHSIA_BUILD_DIR.');
    }
666
    return globals.processUtils.run(<String>[
667 668
      'ssh',
      '-F',
669
      globals.fuchsiaArtifacts.sshConfig.absolute.path,
670
      id, // Device's IP address.
671
      command,
672
    ]);
673 674
  }

675 676
  /// Transfer the file [origin] from the device to [destination].
  Future<RunResult> scp(String origin, String destination) async {
677
    if (globals.fuchsiaArtifacts.sshConfig == null) {
678 679 680
      throwToolExit('Cannot interact with device. No ssh config.\n'
                    'Try setting FUCHSIA_SSH_CONFIG or FUCHSIA_BUILD_DIR.');
    }
681
    return globals.processUtils.run(<String>[
682 683
      'scp',
      '-F',
684
      globals.fuchsiaArtifacts.sshConfig.absolute.path,
685 686 687 688 689
      '$id:$origin',
      destination,
    ]);
  }

690 691 692 693 694 695 696 697
  /// Finds the first port running a VM matching `isolateName` from the
  /// provided set of `ports`.
  ///
  /// Returns null if no isolate port can be found.
  ///
  // TODO(jonahwilliams): replacing this with the hub will require an update
  // to the flutter_runner.
  Future<int> findIsolatePort(String isolateName, List<int> ports) async {
698
    for (final int port in ports) {
699 700 701 702 703 704
      try {
        // Note: The square-bracket enclosure for using the IPv6 loopback
        // didn't appear to work, but when assigning to the IPv4 loopback device,
        // netstat shows that the local port is actually being used on the IPv6
        // loopback (::1).
        final Uri uri = Uri.parse('http://[$_ipv6Loopback]:$port');
705
        final FlutterVmService vmService = await connectToVmService(uri, logger: globals.logger);
706 707
        final List<FlutterView> flutterViews = await vmService.getFlutterViews();
        for (final FlutterView flutterView in flutterViews) {
708 709 710 711
          if (flutterView.uiIsolate == null) {
            continue;
          }
          if (flutterView.uiIsolate.name.contains(isolateName)) {
712
            return vmService.httpAddress.port;
713 714 715
          }
        }
      } on SocketException catch (err) {
716
        globals.printTrace('Failed to connect to $port: $err');
717 718 719 720
      }
    }
    throwToolExit('No ports found running $isolateName');
  }
721

722 723 724
  FuchsiaIsolateDiscoveryProtocol getIsolateDiscoveryProtocol(String isolateName) {
    return FuchsiaIsolateDiscoveryProtocol(this, isolateName);
  }
725 726

  @override
727 728 729
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.fuchsia.existsSync();
  }
730 731 732 733 734

  @override
  Future<void> dispose() async {
    await _portForwarder?.dispose();
  }
735 736 737
}

class FuchsiaIsolateDiscoveryProtocol {
738 739 740
  FuchsiaIsolateDiscoveryProtocol(
    this._device,
    this._isolateName, [
741
    this._vmServiceConnector = _kDefaultFuchsiaIsolateDiscoveryConnector,
742
    this._ddsStarter = _kDefaultDartDevelopmentServiceStarter,
743 744 745 746
    this._pollOnce = false,
  ]);

  static const Duration _pollDuration = Duration(seconds: 10);
747
  final Map<int, FlutterVmService> _ports = <int, FlutterVmService>{};
748 749 750
  final FuchsiaDevice _device;
  final String _isolateName;
  final Completer<Uri> _foundUri = Completer<Uri>();
751
  final Future<FlutterVmService> Function(Uri) _vmServiceConnector;
752
  final Future<void> Function(Device, Uri, bool) _ddsStarter;
753 754 755 756 757 758 759 760 761
  // whether to only poll once.
  final bool _pollOnce;
  Timer _pollingTimer;
  Status _status;

  FutureOr<Uri> get uri {
    if (_uri != null) {
      return _uri;
    }
762
    _status ??= globals.logger.startProgress(
763 764
      'Waiting for a connection from $_isolateName on ${_device.name}...',
    );
765
    unawaited(_findIsolate());  // Completes the _foundUri Future.
766 767 768 769 770
    return _foundUri.future.then((Uri uri) {
      _uri = uri;
      return uri;
    });
  }
771

772 773 774 775 776 777 778 779 780 781 782 783 784 785
  Uri _uri;

  void dispose() {
    if (!_foundUri.isCompleted) {
      _status?.cancel();
      _status = null;
      _pollingTimer?.cancel();
      _pollingTimer = null;
      _foundUri.completeError(Exception('Did not complete'));
    }
  }

  Future<void> _findIsolate() async {
    final List<int> ports = await _device.servicePorts();
786
    for (final int port in ports) {
787
      FlutterVmService service;
788 789 790 791 792 793
      if (_ports.containsKey(port)) {
        service = _ports[port];
      } else {
        final int localPort = await _device.portForwarder.forward(port);
        try {
          final Uri uri = Uri.parse('http://[$_ipv6Loopback]:$localPort');
794 795
          await _ddsStarter(_device, uri, true);
          service = await _vmServiceConnector(_device.dds.uri);
796 797
          _ports[port] = service;
        } on SocketException catch (err) {
798
          globals.printTrace('Failed to connect to $localPort: $err');
799 800 801
          continue;
        }
      }
802 803
      final List<FlutterView> flutterViews = await service.getFlutterViews();
      for (final FlutterView flutterView in flutterViews) {
804 805 806 807
        if (flutterView.uiIsolate == null) {
          continue;
        }
        if (flutterView.uiIsolate.name.contains(_isolateName)) {
808
          _foundUri.complete(_device.ipv6
809 810
              ? Uri.parse('http://[$_ipv6Loopback]:${service.httpAddress.port}/')
              : Uri.parse('http://$_ipv4Loopback:${service.httpAddress.port}/'));
811 812 813 814 815 816 817 818 819 820 821 822
          _status.stop();
          return;
        }
      }
    }
    if (_pollOnce) {
      _foundUri.completeError(Exception('Max iterations exceeded'));
      _status.stop();
      return;
    }
    _pollingTimer = Timer(_pollDuration, _findIsolate);
  }
823 824 825 826 827 828 829 830 831
}

class _FuchsiaPortForwarder extends DevicePortForwarder {
  _FuchsiaPortForwarder(this.device);

  final FuchsiaDevice device;
  final Map<int, Process> _processes = <int, Process>{};

  @override
832
  Future<int> forward(int devicePort, {int hostPort}) async {
833
    hostPort ??= await globals.os.findFreePort();
834 835 836
    if (hostPort == 0) {
      throwToolExit('Failed to forward port $devicePort. No free host-side ports');
    }
837 838 839
    // Note: the provided command works around a bug in -N, see US-515
    // for more explanation.
    final List<String> command = <String>[
840 841 842
      'ssh',
      '-6',
      '-F',
843
      globals.fuchsiaArtifacts.sshConfig.absolute.path,
844 845 846 847 848
      '-nNT',
      '-vvv',
      '-f',
      '-L',
      '$hostPort:$_ipv4Loopback:$devicePort',
849
      device.id, // Device's IP address.
850
      'true',
851
    ];
852
    final Process process = await globals.processManager.start(command);
853
    unawaited(process.exitCode.then((int exitCode) {
854 855 856
      if (exitCode != 0) {
        throwToolExit('Failed to forward port:$devicePort');
      }
857
    }));
858 859 860 861 862 863 864 865 866 867 868 869 870 871 872
    _processes[hostPort] = process;
    _forwardedPorts.add(ForwardedPort(hostPort, devicePort));
    return hostPort;
  }

  @override
  List<ForwardedPort> get forwardedPorts => _forwardedPorts;
  final List<ForwardedPort> _forwardedPorts = <ForwardedPort>[];

  @override
  Future<void> unforward(ForwardedPort forwardedPort) async {
    _forwardedPorts.remove(forwardedPort);
    final Process process = _processes.remove(forwardedPort.hostPort);
    process?.kill();
    final List<String> command = <String>[
873 874
      'ssh',
      '-F',
875
      globals.fuchsiaArtifacts.sshConfig.absolute.path,
876 877 878 879 880
      '-O',
      'cancel',
      '-vvv',
      '-L',
      '${forwardedPort.hostPort}:$_ipv4Loopback:${forwardedPort.devicePort}',
881
      device.id, // Device's IP address.
882
    ];
883
    final ProcessResult result = await globals.processManager.run(command);
884
    if (result.exitCode != 0) {
885 886 887 888 889
      throwToolExit(
        'Unforward command failed:\n'
        'stdout: ${result.stdout}\n'
        'stderr: ${result.stderr}'
      );
890 891
    }
  }
892 893 894

  @override
  Future<void> dispose() async {
895
    final List<ForwardedPort> forwardedPortsCopy =
896
      List<ForwardedPort>.of(forwardedPorts);
897
    for (final ForwardedPort port in forwardedPortsCopy) {
898 899 900
      await unforward(port);
    }
  }
901
}