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

import 'dart:async';

7
import 'package:meta/meta.dart';
8
import 'package:vm_service/vm_service.dart' as vm_service;
9

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

29
import 'application_package.dart';
30
import 'fuchsia_ffx.dart';
31
import 'fuchsia_pm.dart';
32 33
import 'fuchsia_sdk.dart';
import 'fuchsia_workflow.dart';
34
import 'pkgctl.dart';
35 36

/// The [FuchsiaDeviceTools] instance.
37
FuchsiaDeviceTools get fuchsiaDeviceTools => context.get<FuchsiaDeviceTools>()!;
38 39 40

/// Fuchsia device-side tools.
class FuchsiaDeviceTools {
41
  late final FuchsiaPkgctl pkgctl = FuchsiaPkgctl();
42
  late final FuchsiaFfx ffx = FuchsiaFfx();
43 44
}

45 46 47
final String _ipv4Loopback = InternetAddress.loopbackIPv4.address;
final String _ipv6Loopback = InternetAddress.loopbackIPv6.address;

48
// Enables testing the fuchsia isolate discovery
49
Future<FlutterVmService> _kDefaultFuchsiaIsolateDiscoveryConnector(Uri uri) {
50
  return connectToVmService(uri, logger: globals.logger);
51 52
}

53 54 55
Future<void> _kDefaultDartDevelopmentServiceStarter(
  Device device,
  Uri observatoryUri,
56
  bool disableServiceAuthCodes,
57
) async {
58 59
  await device.dds.startDartDevelopmentService(
    observatoryUri,
60 61 62
    hostPort: 0,
    ipv6: true,
    disableServiceAuthCodes: disableServiceAuthCodes,
63
    logger: globals.logger,
64
  );
65 66
}

67 68
/// Read the log for a particular device.
class _FuchsiaLogReader extends DeviceLogReader {
69
  _FuchsiaLogReader(this._device, this._systemClock, [this._app]);
70

71 72
  // \S matches non-whitespace characters.
  static final RegExp _flutterLogOutput = RegExp(r'INFO: \S+\(flutter\): ');
73

74
  final FuchsiaDevice _device;
75
  final ApplicationPackage? _app;
76
  final SystemClock _systemClock;
77

78 79
  @override
  String get name => _device.name;
80

81
  Stream<String>? _logLines;
82 83
  @override
  Stream<String> get logLines {
84
    final Stream<String>? logStream = globals.fuchsiaSdk?.syslogs(_device.id);
85
    _logLines ??= _processLogs(logStream);
86
    return _logLines ?? const Stream<String>.empty();
87 88
  }

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

108 109
  @override
  String toString() => name;
110 111 112 113 114 115

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

118 119 120 121 122 123 124 125 126 127 128 129 130
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;
    }
131
    final String? rawDate = _utcDateOutput.firstMatch(line)?.group(0);
132 133 134 135 136 137 138
    if (rawDate == null) {
      return;
    }
    final DateTime logTime = DateTime.parse(rawDate);
    if (logTime.millisecondsSinceEpoch < _startTime.millisecondsSinceEpoch) {
      return;
    }
139 140
    _outputSink.add(
        '[${logTime.toLocal()}] Flutter: ${line.split(_matchRegExp).last}');
141 142 143
  }

  @override
144
  void addError(Object error, [StackTrace? stackTrace]) {
145 146 147 148
    _outputSink.addError(error, stackTrace);
  }

  @override
149 150 151
  void close() {
    _outputSink.close();
  }
152 153
}

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

  final Platform _platform;
  final FuchsiaWorkflow _fuchsiaWorkflow;
  final FuchsiaSdk _fuchsiaSdk;
  final Logger _logger;
171 172

  @override
173
  bool get supportsPlatform => isFuchsiaSupportedPlatform(_platform);
174 175

  @override
176
  bool get canListAnything => _fuchsiaWorkflow.canListDevices;
177 178

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

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

204
  Future<FuchsiaDevice?> _parseDevice(String text) async {
205
    final String line = text.trim();
206
    // ['ip', 'device name']
207
    final List<String> words = line.split(' ');
208
    if (words.length < 2) {
209
      return null;
210
    }
211
    final String name = words[1];
212

213
    // TODO(omerlevran): Add support for resolve on the FuchsiaSdk Object.
214
    final String? resolvedHost = await _fuchsiaSdk.fuchsiaFfx.resolve(name);
215
    if (resolvedHost == null) {
216 217
      _logger.printError('Failed to resolve host for Fuchsia device `$name`');
      return null;
218
    }
219
    return FuchsiaDevice(resolvedHost, name: name);
220
  }
221 222 223

  @override
  List<String> get wellKnownIds => const <String>[];
224 225
}

226
class FuchsiaDevice extends Device {
227 228 229 230 231 232
  FuchsiaDevice(super.id, {required this.name})
      : super(
          platformType: PlatformType.fuchsia,
          category: null,
          ephemeral: true,
        );
233 234

  @override
235 236 237 238
  bool get supportsHotReload => true;

  @override
  bool get supportsHotRestart => false;
239

240
  @override
241
  bool get supportsFlutterExit => false;
242

243 244 245 246
  @override
  final String name;

  @override
247
  Future<bool> get isLocalEmulator async => false;
248

249
  @override
250
  Future<String?> get emulatorId async => null;
251

252 253 254
  @override
  bool get supportsStartPaused => false;

255
  late final Future<bool> isSession = _initIsSession();
256 257 258

  /// Determine if the Fuchsia device is running a session based build.
  ///
259 260 261
  /// If the device is running a session based build, `ffx session` should be
  /// used to launch apps. Fuchsia flutter apps cannot currently be launched
  /// without a session.
262
  Future<bool> _initIsSession() async {
263
    return await globals.fuchsiaSdk?.fuchsiaFfx.sessionShow() != null;
264 265
  }

266
  @override
267 268
  Future<bool> isAppInstalled(
    ApplicationPackage app, {
269
    String? userIdentifier,
270
  }) async => false;
271 272

  @override
273
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;
274 275

  @override
276 277
  Future<bool> installApp(
    ApplicationPackage app, {
278
    String? userIdentifier,
279
  }) => Future<bool>.value(false);
280 281

  @override
282 283
  Future<bool> uninstallApp(
    ApplicationPackage app, {
284
    String? userIdentifier,
285
  }) async => false;
286 287 288 289

  @override
  bool isSupported() => true;

290
  @override
291 292
  bool supportsRuntimeMode(BuildMode buildMode) =>
      buildMode != BuildMode.jitRelease;
293

294 295
  @override
  Future<LaunchResult> startApp(
296
    covariant FuchsiaApp package, {
297 298 299 300
    String? mainPath,
    String? route,
    required DebuggingOptions debuggingOptions,
    Map<String, Object?> platformArgs = const <String, Object?>{},
301 302
    bool prebuiltApplication = false,
    bool ipv6 = false,
303
    String? userIdentifier,
304
  }) async {
305 306 307 308 309 310
    if (await isSession) {
      globals.printTrace('Running on a session framework based build.');
    } else {
      globals.printTrace('Running on a non session framework based build.');
    }

311
    if (!prebuiltApplication) {
312 313 314 315 316
      throwToolExit(
        'This tool does not currently build apps for fuchsia.\n'
        'Build the app using a supported Fuchsia workflow.\n'
        'Then use the --${FlutterOptions.kUseApplicationBinary} flag.'
      );
317 318 319
    }
    // Stop the app if it's currently running.
    await stopApp(package);
320

321
    // Find out who the device thinks we are.
322
    final int port = await globals.os.findFreePort();
323
    if (port == 0) {
324
      globals.printError('Failed to find a free port');
325 326
      return LaunchResult.failed();
    }
327 328 329

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

    final String appName = FlutterProject.current().manifest.appName;
344
    final Status status = globals.logger.startProgress(
345
      'Starting Fuchsia application $appName...',
346
    );
347
    FuchsiaPackageServer? fuchsiaPackageServer;
348
    bool serverRegistered = false;
349
    String fuchsiaUrl;
350 351
    try {
      // Start up a package server.
352
      const String packageServerName = FuchsiaPackageServer.toolHost;
353 354
      fuchsiaPackageServer =
          FuchsiaPackageServer(packageRepo.path, packageServerName, '', port);
355
      if (!await fuchsiaPackageServer.start()) {
356
        globals.printError('Failed to start the Fuchsia package server');
357 358
        return LaunchResult.failed();
      }
359 360

      // Serve the application's package.
361 362
      final File farArchive =
          package.farArchive(debuggingOptions.buildInfo.mode);
363
      if (!await fuchsiaPackageServer.addPackage(farArchive)) {
364
        globals.printError('Failed to add package to the package server');
365 366 367
        return LaunchResult.failed();
      }

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

381
      // Teach the package controller about the package server.
382 383
      if (!await fuchsiaDeviceTools.pkgctl
          .addRepo(this, fuchsiaPackageServer)) {
384
        globals.printError('Failed to teach amber about the package server');
385 386 387 388
        return LaunchResult.failed();
      }
      serverRegistered = true;

389
      // Tell the package controller to prefetch the flutter_runner.
390 391 392 393 394 395 396 397 398 399 400 401 402
      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';
        }
403
      }
404 405 406 407 408

      if (!await fuchsiaDeviceTools.pkgctl
          .resolve(this, fuchsiaPackageServer.name, flutterRunnerName)) {
        globals
            .printError('Failed to get pkgctl to prefetch the flutter_runner');
409 410 411
        return LaunchResult.failed();
      }

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

419
      fuchsiaUrl = 'fuchsia-pkg://$packageServerName/$appName#meta/$appName.cm';
420

421
      if (await isSession) {
422 423 424 425 426
        // Instruct ffx session to start the app
        final bool addedApp =
            await globals.fuchsiaSdk?.fuchsiaFfx.sessionAdd(fuchsiaUrl) ?? false;
        if (!addedApp) {
          globals.printError('Failed to add the app via `ffx session add`');
427 428 429
          return LaunchResult.failed();
        }
      } else {
430 431 432
        globals.printError(
            'Fuchsia flutter apps can only be launched within a session');
        return LaunchResult.failed();
433 434
      }
    } finally {
435 436
      // Try to un-teach the package controller about the package server if
      // needed.
437
      if (serverRegistered && fuchsiaPackageServer != null) {
438
        await fuchsiaDeviceTools.pkgctl.rmRepo(this, fuchsiaPackageServer);
439 440
      }
      // Shutdown the package server and delete the package repo;
441
      globals.printTrace("Shutting down the tool's package server.");
442
      fuchsiaPackageServer?.stop();
443 444
      globals.printTrace(
          "Removing the tool's package repo: at ${packageRepo.path}");
445 446
      try {
        packageRepo.deleteSync(recursive: true);
447
      } on Exception catch (e) {
448
        globals.printError('Failed to remove Fuchsia package repo directory '
449
            'at ${packageRepo.path}: $e.');
450
      }
451 452 453
      status.cancel();
    }

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

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

  @override
473 474
  Future<bool> stopApp(
    covariant FuchsiaApp app, {
475
    String? userIdentifier,
476
  }) async {
477 478 479 480 481
    if (await isSession) {
      // Currently there are no way to close a running app programmatically
      // using the session framework afaik. So this is a no-op.
      return true;
    }
482 483
    // Fuchsia flutter apps currently require a session, but if that changes,
    // add the relevant "stopApp" code here.
484
    return true;
485 486
  }

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

515
  @override
516
  bool get supportsScreenshot => isFuchsiaSupportedPlatform(globals.platform);
517 518 519 520

  @override
  Future<void> takeScreenshot(File outputFile) async {
    if (outputFile.basename.split('.').last != 'ppm') {
521
      throw Exception('${outputFile.path} must be a .ppm file');
522
    }
523 524
    final RunResult screencapResult =
        await shell('screencap > /tmp/screenshot.ppm');
525
    if (screencapResult.exitCode != 0) {
526 527
      throw Exception(
          'Could not take a screenshot on device $name:\n$screencapResult');
528 529
    }
    try {
530 531
      final RunResult scpResult =
          await scp('/tmp/screenshot.ppm', outputFile.path);
532
      if (scpResult.exitCode != 0) {
533
        throw Exception('Failed to copy screenshot from device:\n$scpResult');
534 535 536 537 538 539
      }
    } finally {
      try {
        final RunResult deleteResult = await shell('rm /tmp/screenshot.ppm');
        if (deleteResult.exitCode != 0) {
          globals.printError(
540
              'Failed to delete screenshot.ppm from the device:\n$deleteResult');
541
        }
542
      } on Exception catch (e) {
543 544
        globals
            .printError('Failed to delete screenshot.ppm from the device: $e');
545 546 547 548
      }
    }
  }

549
  @override
550
  late final Future<TargetPlatform> targetPlatform = _queryTargetPlatform();
551 552

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

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

583
  _FuchsiaLogReader? _logReader;
584 585

  @override
586 587
  DevicePortForwarder get portForwarder =>
      _portForwarder ??= _FuchsiaPortForwarder(this);
588
  DevicePortForwarder? _portForwarder;
589 590 591 592 593

  @visibleForTesting
  set portForwarder(DevicePortForwarder forwarder) {
    _portForwarder = forwarder;
  }
594 595

  @override
596
  void clearLogs() {}
597

598
  /// [true] if the current host address is IPv6.
599
  late final bool ipv6 = isIPv6Address(id);
600 601 602

  /// Return the address that the device should use to communicate with the
  /// host.
603
  late final Future<String> hostAddress = () async {
604
    final RunResult result = await shell(r'echo $SSH_CONNECTION');
605 606 607
    void fail() {
      throwToolExit('Failed to get local address, aborting.\n$result');
    }
608

609 610 611 612 613 614 615 616 617 618 619
    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();
    }
620 621
    return addr;
  }();
622

623 624
  /// List the ports currently running a dart observatory.
  Future<List<int>> servicePorts() async {
625 626 627
    const String findCommand = 'find /hub -name vmservice-port';
    final RunResult findResult = await shell(findCommand);
    if (findResult.exitCode != 0) {
628 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
        if (line == '') {
          continue;
        }
651
        final int? port = int.tryParse(line);
652 653 654 655 656 657
        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 663
    final File? sshConfig = globals.fuchsiaArtifacts?.sshConfig;
    if (sshConfig == null) {
664
      throwToolExit('Cannot interact with device. No ssh config.\n'
665
          'Try setting FUCHSIA_SSH_CONFIG or FUCHSIA_BUILD_DIR.');
666
    }
667
    return globals.processUtils.run(<String>[
668 669
      'ssh',
      '-F',
670
      sshConfig.absolute.path,
671
      id, // Device's IP address.
672
      command,
673
    ]);
674 675
  }

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

692 693 694 695 696
  /// Finds the first port running a VM matching `isolateName` from the
  /// provided set of `ports`.
  ///
  /// Returns null if no isolate port can be found.
  Future<int> findIsolatePort(String isolateName, List<int> ports) async {
697
    for (final int port in ports) {
698 699 700 701 702 703
      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');
704 705 706 707
        final FlutterVmService vmService =
            await connectToVmService(uri, logger: globals.logger);
        final List<FlutterView> flutterViews =
            await vmService.getFlutterViews();
708
        for (final FlutterView flutterView in flutterViews) {
709 710
          final vm_service.IsolateRef? uiIsolate = flutterView.uiIsolate;
          if (uiIsolate == null) {
711 712
            continue;
          }
713
          final int? port = vmService.httpAddress?.port;
714 715
          if (port != null &&
              (uiIsolate.name?.contains(isolateName) ?? false)) {
716
            return port;
717 718 719
          }
        }
      } on SocketException catch (err) {
720
        globals.printTrace('Failed to connect to $port: $err');
721 722 723 724
      }
    }
    throwToolExit('No ports found running $isolateName');
  }
725

726 727
  FuchsiaIsolateDiscoveryProtocol getIsolateDiscoveryProtocol(
      String isolateName) {
728 729
    return FuchsiaIsolateDiscoveryProtocol(this, isolateName);
  }
730 731

  @override
732 733 734
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.fuchsia.existsSync();
  }
735 736 737 738 739

  @override
  Future<void> dispose() async {
    await _portForwarder?.dispose();
  }
740 741 742
}

class FuchsiaIsolateDiscoveryProtocol {
743 744 745
  FuchsiaIsolateDiscoveryProtocol(
    this._device,
    this._isolateName, [
746
    this._vmServiceConnector = _kDefaultFuchsiaIsolateDiscoveryConnector,
747
    this._ddsStarter = _kDefaultDartDevelopmentServiceStarter,
748 749 750 751
    this._pollOnce = false,
  ]);

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

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

777
  Uri? _uri;
778 779 780 781 782 783 784 785 786 787 788 789 790

  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();
791
    for (final int port in ports) {
792
      FlutterVmService? service;
793 794 795 796 797 798
      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');
799
          await _ddsStarter(_device, uri, true);
800
          service = await _vmServiceConnector(_device.dds.uri!);
801 802
          _ports[port] = service;
        } on SocketException catch (err) {
803
          globals.printTrace('Failed to connect to $localPort: $err');
804 805 806
          continue;
        }
      }
807 808
      final List<FlutterView> flutterViews =
          await service?.getFlutterViews() ?? <FlutterView>[];
809
      for (final FlutterView flutterView in flutterViews) {
810 811
        final vm_service.IsolateRef? uiIsolate = flutterView.uiIsolate;
        if (uiIsolate == null) {
812 813
          continue;
        }
814
        final int? port = service?.httpAddress?.port;
815
        if (port != null && (uiIsolate.name?.contains(_isolateName) ?? false)) {
816
          _foundUri.complete(_device.ipv6
817 818 819
              ? Uri.parse('http://[$_ipv6Loopback]:$port/')
              : Uri.parse('http://$_ipv4Loopback:$port/'));
          _status?.stop();
820 821 822 823 824 825
          return;
        }
      }
    }
    if (_pollOnce) {
      _foundUri.completeError(Exception('Max iterations exceeded'));
826
      _status?.stop();
827 828 829 830
      return;
    }
    _pollingTimer = Timer(_pollDuration, _findIsolate);
  }
831 832 833 834 835 836 837 838 839
}

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

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

  @override
840
  Future<int> forward(int devicePort, {int? hostPort}) async {
841
    hostPort ??= await globals.os.findFreePort();
842
    if (hostPort == 0) {
843 844
      throwToolExit(
          'Failed to forward port $devicePort. No free host-side ports');
845
    }
846 847 848 849 850
    final File? sshConfig = globals.fuchsiaArtifacts?.sshConfig;
    if (sshConfig == null) {
      throwToolExit('Cannot interact with device. No ssh config.\n'
          'Try setting FUCHSIA_SSH_CONFIG or FUCHSIA_BUILD_DIR.');
    }
851 852 853
    // Note: the provided command works around a bug in -N, see US-515
    // for more explanation.
    final List<String> command = <String>[
854 855 856
      'ssh',
      '-6',
      '-F',
857
      sshConfig.absolute.path,
858 859 860 861 862
      '-nNT',
      '-vvv',
      '-f',
      '-L',
      '$hostPort:$_ipv4Loopback:$devicePort',
863
      device.id, // Device's IP address.
864
      'true',
865
    ];
866
    final Process process = await globals.processManager.start(command);
867
    unawaited(process.exitCode.then((int exitCode) {
868 869 870
      if (exitCode != 0) {
        throwToolExit('Failed to forward port:$devicePort');
      }
871
    }));
872 873 874 875 876 877 878 879 880 881 882 883
    _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);
884
    final Process? process = _processes.remove(forwardedPort.hostPort);
885
    process?.kill();
886 887 888 889 890
    final File? sshConfig = globals.fuchsiaArtifacts?.sshConfig;
    if (sshConfig == null) {
      // Nothing to cancel.
      return;
    }
891
    final List<String> command = <String>[
892 893
      'ssh',
      '-F',
894
      sshConfig.absolute.path,
895 896 897 898 899
      '-O',
      'cancel',
      '-vvv',
      '-L',
      '${forwardedPort.hostPort}:$_ipv4Loopback:${forwardedPort.devicePort}',
900
      device.id, // Device's IP address.
901
    ];
902
    final ProcessResult result = await globals.processManager.run(command);
903
    if (result.exitCode != 0) {
904 905 906
      throwToolExit(
        'Unforward command failed:\n'
        'stdout: ${result.stdout}\n'
907
        'stderr: ${result.stderr}',
908
      );
909 910
    }
  }
911 912 913

  @override
  Future<void> dispose() async {
914
    final List<ForwardedPort> forwardedPortsCopy =
915
        List<ForwardedPort>.of(forwardedPorts);
916
    for (final ForwardedPort port in forwardedPortsCopy) {
917 918 919
      await unforward(port);
    }
  }
920
}