1// Copyright 2017 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'dart:async'; 6 7import 'package:meta/meta.dart'; 8 9import '../application_package.dart'; 10import '../artifacts.dart'; 11import '../base/common.dart'; 12import '../base/context.dart'; 13import '../base/file_system.dart'; 14import '../base/io.dart'; 15import '../base/logger.dart'; 16import '../base/os.dart'; 17import '../base/platform.dart'; 18import '../base/process.dart'; 19import '../base/process_manager.dart'; 20import '../base/time.dart'; 21import '../build_info.dart'; 22import '../device.dart'; 23import '../globals.dart'; 24import '../project.dart'; 25import '../vmservice.dart'; 26 27import 'amber_ctl.dart'; 28import 'application_package.dart'; 29import 'fuchsia_build.dart'; 30import 'fuchsia_pm.dart'; 31import 'fuchsia_sdk.dart'; 32import 'fuchsia_workflow.dart'; 33import 'tiles_ctl.dart'; 34 35/// The [FuchsiaDeviceTools] instance. 36FuchsiaDeviceTools get fuchsiaDeviceTools => context.get<FuchsiaDeviceTools>(); 37 38/// Fuchsia device-side tools. 39class FuchsiaDeviceTools { 40 FuchsiaAmberCtl _amberCtl; 41 FuchsiaAmberCtl get amberCtl => _amberCtl ??= FuchsiaAmberCtl(); 42 43 FuchsiaTilesCtl _tilesCtl; 44 FuchsiaTilesCtl get tilesCtl => _tilesCtl ??= FuchsiaTilesCtl(); 45} 46 47final String _ipv4Loopback = InternetAddress.loopbackIPv4.address; 48final String _ipv6Loopback = InternetAddress.loopbackIPv6.address; 49 50// Enables testing the fuchsia isolate discovery 51Future<VMService> _kDefaultFuchsiaIsolateDiscoveryConnector(Uri uri) { 52 return VMService.connect(uri); 53} 54 55/// Read the log for a particular device. 56class _FuchsiaLogReader extends DeviceLogReader { 57 _FuchsiaLogReader(this._device, [this._app]); 58 59 // \S matches non-whitespace characters. 60 static final RegExp _flutterLogOutput = RegExp(r'INFO: \S+\(flutter\): '); 61 62 final FuchsiaDevice _device; 63 final ApplicationPackage _app; 64 65 @override 66 String get name => _device.name; 67 68 Stream<String> _logLines; 69 @override 70 Stream<String> get logLines { 71 final Stream<String> logStream = fuchsiaSdk.syslogs(_device.id); 72 _logLines ??= _processLogs(logStream); 73 return _logLines; 74 } 75 76 Stream<String> _processLogs(Stream<String> lines) { 77 if (lines == null) { 78 return null; 79 } 80 // Get the starting time of the log processor to filter logs from before 81 // the process attached. 82 final DateTime startTime = systemClock.now(); 83 // Determine if line comes from flutter, and optionally whether it matches 84 // the correct fuchsia module. 85 final RegExp matchRegExp = _app == null 86 ? _flutterLogOutput 87 : RegExp('INFO: ${_app.name}(\.cmx)?\\(flutter\\): '); 88 return Stream<String>.eventTransformed( 89 lines, 90 (Sink<String> outout) => _FuchsiaLogSink(outout, matchRegExp, startTime), 91 ); 92 } 93 94 @override 95 String toString() => name; 96} 97 98class _FuchsiaLogSink implements EventSink<String> { 99 _FuchsiaLogSink(this._outputSink, this._matchRegExp, this._startTime); 100 101 static final RegExp _utcDateOutput = RegExp(r'\d+\-\d+\-\d+ \d+:\d+:\d+'); 102 final EventSink<String> _outputSink; 103 final RegExp _matchRegExp; 104 final DateTime _startTime; 105 106 @override 107 void add(String line) { 108 if (!_matchRegExp.hasMatch(line)) { 109 return; 110 } 111 final String rawDate = _utcDateOutput.firstMatch(line)?.group(0); 112 if (rawDate == null) { 113 return; 114 } 115 final DateTime logTime = DateTime.parse(rawDate); 116 if (logTime.millisecondsSinceEpoch < _startTime.millisecondsSinceEpoch) { 117 return; 118 } 119 _outputSink.add( 120 '[${logTime.toLocal()}] Flutter: ${line.split(_matchRegExp).last}'); 121 } 122 123 @override 124 void addError(Object error, [StackTrace stackTrace]) { 125 _outputSink.addError(error, stackTrace); 126 } 127 128 @override 129 void close() { 130 _outputSink.close(); 131 } 132} 133 134class FuchsiaDevices extends PollingDeviceDiscovery { 135 FuchsiaDevices() : super('Fuchsia devices'); 136 137 @override 138 bool get supportsPlatform => platform.isLinux || platform.isMacOS; 139 140 @override 141 bool get canListAnything => fuchsiaWorkflow.canListDevices; 142 143 @override 144 Future<List<Device>> pollingGetDevices() async { 145 if (!fuchsiaWorkflow.canListDevices) { 146 return <Device>[]; 147 } 148 final String text = await fuchsiaSdk.listDevices(); 149 if (text == null || text.isEmpty) { 150 return <Device>[]; 151 } 152 final List<FuchsiaDevice> devices = parseListDevices(text); 153 return devices; 154 } 155 156 @override 157 Future<List<String>> getDiagnostics() async => const <String>[]; 158} 159 160@visibleForTesting 161List<FuchsiaDevice> parseListDevices(String text) { 162 final List<FuchsiaDevice> devices = <FuchsiaDevice>[]; 163 for (String rawLine in text.trim().split('\n')) { 164 final String line = rawLine.trim(); 165 // ['ip', 'device name'] 166 final List<String> words = line.split(' '); 167 if (words.length < 2) { 168 continue; 169 } 170 final String name = words[1]; 171 final String id = words[0]; 172 devices.add(FuchsiaDevice(id, name: name)); 173 } 174 return devices; 175} 176 177class FuchsiaDevice extends Device { 178 FuchsiaDevice(String id, {this.name}) : super( 179 id, 180 platformType: PlatformType.fuchsia, 181 category: null, 182 ephemeral: false, 183 ); 184 185 @override 186 bool get supportsHotReload => true; 187 188 @override 189 bool get supportsHotRestart => false; 190 191 @override 192 bool get supportsFlutterExit => false; 193 194 @override 195 final String name; 196 197 @override 198 Future<bool> get isLocalEmulator async => false; 199 200 @override 201 Future<String> get emulatorId async => null; 202 203 @override 204 bool get supportsStartPaused => false; 205 206 @override 207 Future<bool> isAppInstalled(ApplicationPackage app) async => false; 208 209 @override 210 Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false; 211 212 @override 213 Future<bool> installApp(ApplicationPackage app) => Future<bool>.value(false); 214 215 @override 216 Future<bool> uninstallApp(ApplicationPackage app) async => false; 217 218 @override 219 bool isSupported() => true; 220 221 @override 222 Future<LaunchResult> startApp( 223 covariant FuchsiaApp package, { 224 String mainPath, 225 String route, 226 DebuggingOptions debuggingOptions, 227 Map<String, dynamic> platformArgs, 228 bool prebuiltApplication = false, 229 bool usesTerminalUi = true, 230 bool ipv6 = false, 231 }) async { 232 if (!prebuiltApplication) { 233 await buildFuchsia(fuchsiaProject: FlutterProject.current().fuchsia, 234 target: mainPath, 235 buildInfo: debuggingOptions.buildInfo); 236 } 237 // Stop the app if it's currently running. 238 await stopApp(package); 239 // Find out who the device thinks we are. 240 final String host = await fuchsiaSdk.fuchsiaDevFinder.resolve(name); 241 if (host == null) { 242 printError('Failed to resolve host for Fuchsia device'); 243 return LaunchResult.failed(); 244 } 245 final int port = await os.findFreePort(); 246 if (port == 0) { 247 printError('Failed to find a free port'); 248 return LaunchResult.failed(); 249 } 250 final Directory packageRepo = 251 fs.directory(fs.path.join(getFuchsiaBuildDirectory(), '.pkg-repo')); 252 packageRepo.createSync(recursive: true); 253 254 final String appName = FlutterProject.current().manifest.appName; 255 256 final Status status = logger.startProgress( 257 'Starting Fuchsia application...', 258 timeout: null, 259 ); 260 FuchsiaPackageServer fuchsiaPackageServer; 261 bool serverRegistered = false; 262 try { 263 // Ask amber to pre-fetch some things we'll need before setting up our own 264 // package server. This is to avoid relying on amber correctly using 265 // multiple package servers, support for which is in flux. 266 if (!await fuchsiaDeviceTools.amberCtl.getUp(this, 'tiles')) { 267 printError('Failed to get amber to prefetch tiles'); 268 return LaunchResult.failed(); 269 } 270 if (!await fuchsiaDeviceTools.amberCtl.getUp(this, 'tiles_ctl')) { 271 printError('Failed to get amber to prefetch tiles_ctl'); 272 return LaunchResult.failed(); 273 } 274 275 // Start up a package server. 276 const String packageServerName = 'flutter_tool'; 277 fuchsiaPackageServer = FuchsiaPackageServer( 278 packageRepo.path, packageServerName, host, port); 279 if (!await fuchsiaPackageServer.start()) { 280 printError('Failed to start the Fuchsia package server'); 281 return LaunchResult.failed(); 282 } 283 final File farArchive = package.farArchive( 284 debuggingOptions.buildInfo.mode); 285 if (!await fuchsiaPackageServer.addPackage(farArchive)) { 286 printError('Failed to add package to the package server'); 287 return LaunchResult.failed(); 288 } 289 290 // Teach the package controller about the package server. 291 if (!await fuchsiaDeviceTools.amberCtl.addRepoCfg(this, fuchsiaPackageServer)) { 292 printError('Failed to teach amber about the package server'); 293 return LaunchResult.failed(); 294 } 295 serverRegistered = true; 296 297 // Tell the package controller to prefetch the app. 298 if (!await fuchsiaDeviceTools.amberCtl.pkgCtlResolve( 299 this, fuchsiaPackageServer, appName)) { 300 printError('Failed to get pkgctl to prefetch the package'); 301 return LaunchResult.failed(); 302 } 303 304 // Ensure tiles_ctl is started, and start the app. 305 if (!await FuchsiaTilesCtl.ensureStarted(this)) { 306 printError('Failed to ensure that tiles is started on the device'); 307 return LaunchResult.failed(); 308 } 309 310 // Instruct tiles_ctl to start the app. 311 final String fuchsiaUrl = 312 'fuchsia-pkg://$packageServerName/$appName#meta/$appName.cmx'; 313 if (!await fuchsiaDeviceTools.tilesCtl.add(this, fuchsiaUrl, <String>[])) { 314 printError('Failed to add the app to tiles'); 315 return LaunchResult.failed(); 316 } 317 } finally { 318 // Try to un-teach the package controller about the package server if 319 // needed. 320 if (serverRegistered) { 321 await fuchsiaDeviceTools.amberCtl.pkgCtlRepoRemove(this, fuchsiaPackageServer); 322 } 323 // Shutdown the package server and delete the package repo; 324 fuchsiaPackageServer?.stop(); 325 packageRepo.deleteSync(recursive: true); 326 status.cancel(); 327 } 328 329 if (!debuggingOptions.buildInfo.isDebug && 330 !debuggingOptions.buildInfo.isProfile) { 331 return LaunchResult.succeeded(); 332 } 333 334 // In a debug or profile build, try to find the observatory uri. 335 final FuchsiaIsolateDiscoveryProtocol discovery = 336 getIsolateDiscoveryProtocol(appName); 337 try { 338 final Uri observatoryUri = await discovery.uri; 339 return LaunchResult.succeeded(observatoryUri: observatoryUri); 340 } finally { 341 discovery.dispose(); 342 } 343 } 344 345 @override 346 Future<bool> stopApp(covariant FuchsiaApp app) async { 347 final int appKey = await FuchsiaTilesCtl.findAppKey(this, app.id); 348 if (appKey != -1) { 349 if (!await fuchsiaDeviceTools.tilesCtl.remove(this, appKey)) { 350 printError('tiles_ctl remove on ${app.id} failed.'); 351 return false; 352 } 353 } 354 return true; 355 } 356 357 @override 358 Future<TargetPlatform> get targetPlatform async => TargetPlatform.fuchsia; 359 360 @override 361 Future<String> get sdkNameAndVersion async { 362 const String versionPath = '/pkgfs/packages/build-info/0/data/version'; 363 final RunResult catResult = await shell('cat $versionPath'); 364 if (catResult.exitCode != 0) { 365 printTrace('Failed to cat $versionPath: ${catResult.stderr}'); 366 return 'Fuchsia'; 367 } 368 final String version = catResult.stdout.trim(); 369 if (version.isEmpty) { 370 printTrace('$versionPath was empty'); 371 return 'Fuchsia'; 372 } 373 return 'Fuchsia $version'; 374 } 375 376 @override 377 DeviceLogReader getLogReader({ApplicationPackage app}) => 378 _logReader ??= _FuchsiaLogReader(this, app); 379 _FuchsiaLogReader _logReader; 380 381 @override 382 DevicePortForwarder get portForwarder => 383 _portForwarder ??= _FuchsiaPortForwarder(this); 384 _FuchsiaPortForwarder _portForwarder; 385 386 @override 387 void clearLogs() {} 388 389 @override 390 OverrideArtifacts get artifactOverrides { 391 return _artifactOverrides ??= OverrideArtifacts( 392 parent: Artifacts.instance, 393 platformKernelDill: fuchsiaArtifacts.platformKernelDill, 394 flutterPatchedSdk: fuchsiaArtifacts.flutterPatchedSdk, 395 ); 396 } 397 OverrideArtifacts _artifactOverrides; 398 399 @override 400 bool get supportsScreenshot => false; 401 402 bool get ipv6 { 403 // Workaround for https://github.com/dart-lang/sdk/issues/29456 404 final String fragment = id.split('%').first; 405 try { 406 Uri.parseIPv6Address(fragment); 407 return true; 408 } on FormatException { 409 return false; 410 } 411 } 412 413 /// List the ports currently running a dart observatory. 414 Future<List<int>> servicePorts() async { 415 const String findCommand = 'find /hub -name vmservice-port'; 416 final RunResult findResult = await shell(findCommand); 417 if (findResult.exitCode != 0) { 418 throwToolExit("'$findCommand' on device $id failed. stderr: '${findResult.stderr}'"); 419 return null; 420 } 421 final String findOutput = findResult.stdout; 422 if (findOutput.trim() == '') { 423 throwToolExit( 424 'No Dart Observatories found. Are you running a debug build?'); 425 return null; 426 } 427 final List<int> ports = <int>[]; 428 for (String path in findOutput.split('\n')) { 429 if (path == '') { 430 continue; 431 } 432 final String lsCommand = 'ls $path'; 433 final RunResult lsResult = await shell(lsCommand); 434 if (lsResult.exitCode != 0) { 435 throwToolExit("'$lsCommand' on device $id failed"); 436 return null; 437 } 438 final String lsOutput = lsResult.stdout; 439 for (String line in lsOutput.split('\n')) { 440 if (line == '') { 441 continue; 442 } 443 final int port = int.tryParse(line); 444 if (port != null) { 445 ports.add(port); 446 } 447 } 448 } 449 return ports; 450 } 451 452 /// Run `command` on the Fuchsia device shell. 453 Future<RunResult> shell(String command) async { 454 if (fuchsiaArtifacts.sshConfig == null) { 455 throwToolExit('Cannot interact with device. No ssh config.\n' 456 'Try setting FUCHSIA_SSH_CONFIG or FUCHSIA_BUILD_DIR.'); 457 } 458 return await runAsync(<String>[ 459 'ssh', 460 '-F', 461 fuchsiaArtifacts.sshConfig.absolute.path, 462 id, 463 command 464 ]); 465 } 466 467 /// Finds the first port running a VM matching `isolateName` from the 468 /// provided set of `ports`. 469 /// 470 /// Returns null if no isolate port can be found. 471 /// 472 // TODO(jonahwilliams): replacing this with the hub will require an update 473 // to the flutter_runner. 474 Future<int> findIsolatePort(String isolateName, List<int> ports) async { 475 for (int port in ports) { 476 try { 477 // Note: The square-bracket enclosure for using the IPv6 loopback 478 // didn't appear to work, but when assigning to the IPv4 loopback device, 479 // netstat shows that the local port is actually being used on the IPv6 480 // loopback (::1). 481 final Uri uri = Uri.parse('http://[$_ipv6Loopback]:$port'); 482 final VMService vmService = await VMService.connect(uri); 483 await vmService.getVM(); 484 await vmService.refreshViews(); 485 for (FlutterView flutterView in vmService.vm.views) { 486 if (flutterView.uiIsolate == null) { 487 continue; 488 } 489 final Uri address = flutterView.owner.vmService.httpAddress; 490 if (flutterView.uiIsolate.name.contains(isolateName)) { 491 return address.port; 492 } 493 } 494 } on SocketException catch (err) { 495 printTrace('Failed to connect to $port: $err'); 496 } 497 } 498 throwToolExit('No ports found running $isolateName'); 499 return null; 500 } 501 502 FuchsiaIsolateDiscoveryProtocol getIsolateDiscoveryProtocol( 503 String isolateName) => 504 FuchsiaIsolateDiscoveryProtocol(this, isolateName); 505 506 @override 507 bool isSupportedForProject(FlutterProject flutterProject) { 508 return flutterProject.fuchsia.existsSync(); 509 } 510} 511 512class FuchsiaIsolateDiscoveryProtocol { 513 FuchsiaIsolateDiscoveryProtocol( 514 this._device, 515 this._isolateName, [ 516 this._vmServiceConnector = _kDefaultFuchsiaIsolateDiscoveryConnector, 517 this._pollOnce = false, 518 ]); 519 520 static const Duration _pollDuration = Duration(seconds: 10); 521 final Map<int, VMService> _ports = <int, VMService>{}; 522 final FuchsiaDevice _device; 523 final String _isolateName; 524 final Completer<Uri> _foundUri = Completer<Uri>(); 525 final Future<VMService> Function(Uri) _vmServiceConnector; 526 // whether to only poll once. 527 final bool _pollOnce; 528 Timer _pollingTimer; 529 Status _status; 530 531 FutureOr<Uri> get uri { 532 if (_uri != null) { 533 return _uri; 534 } 535 _status ??= logger.startProgress( 536 'Waiting for a connection from $_isolateName on ${_device.name}...', 537 timeout: null, // could take an arbitrary amount of time 538 ); 539 _pollingTimer ??= Timer(_pollDuration, _findIsolate); 540 return _foundUri.future.then((Uri uri) { 541 _uri = uri; 542 return uri; 543 }); 544 } 545 546 Uri _uri; 547 548 void dispose() { 549 if (!_foundUri.isCompleted) { 550 _status?.cancel(); 551 _status = null; 552 _pollingTimer?.cancel(); 553 _pollingTimer = null; 554 _foundUri.completeError(Exception('Did not complete')); 555 } 556 } 557 558 Future<void> _findIsolate() async { 559 final List<int> ports = await _device.servicePorts(); 560 for (int port in ports) { 561 VMService service; 562 if (_ports.containsKey(port)) { 563 service = _ports[port]; 564 } else { 565 final int localPort = await _device.portForwarder.forward(port); 566 try { 567 final Uri uri = Uri.parse('http://[$_ipv6Loopback]:$localPort'); 568 service = await _vmServiceConnector(uri); 569 _ports[port] = service; 570 } on SocketException catch (err) { 571 printTrace('Failed to connect to $localPort: $err'); 572 continue; 573 } 574 } 575 await service.getVM(); 576 await service.refreshViews(); 577 for (FlutterView flutterView in service.vm.views) { 578 if (flutterView.uiIsolate == null) { 579 continue; 580 } 581 final Uri address = flutterView.owner.vmService.httpAddress; 582 if (flutterView.uiIsolate.name.contains(_isolateName)) { 583 _foundUri.complete(_device.ipv6 584 ? Uri.parse('http://[$_ipv6Loopback]:${address.port}/') 585 : Uri.parse('http://$_ipv4Loopback:${address.port}/')); 586 _status.stop(); 587 return; 588 } 589 } 590 } 591 if (_pollOnce) { 592 _foundUri.completeError(Exception('Max iterations exceeded')); 593 _status.stop(); 594 return; 595 } 596 _pollingTimer = Timer(_pollDuration, _findIsolate); 597 } 598} 599 600class _FuchsiaPortForwarder extends DevicePortForwarder { 601 _FuchsiaPortForwarder(this.device); 602 603 final FuchsiaDevice device; 604 final Map<int, Process> _processes = <int, Process>{}; 605 606 @override 607 Future<int> forward(int devicePort, {int hostPort}) async { 608 hostPort ??= await os.findFreePort(); 609 if (hostPort == 0) { 610 throwToolExit('Failed to forward port $devicePort. No free host-side ports'); 611 } 612 // Note: the provided command works around a bug in -N, see US-515 613 // for more explanation. 614 final List<String> command = <String>[ 615 'ssh', 616 '-6', 617 '-F', 618 fuchsiaArtifacts.sshConfig.absolute.path, 619 '-nNT', 620 '-vvv', 621 '-f', 622 '-L', 623 '$hostPort:$_ipv4Loopback:$devicePort', 624 device.id, 625 'true', 626 ]; 627 final Process process = await processManager.start(command); 628 unawaited(process.exitCode.then((int exitCode) { 629 if (exitCode != 0) { 630 throwToolExit('Failed to forward port:$devicePort'); 631 } 632 })); 633 _processes[hostPort] = process; 634 _forwardedPorts.add(ForwardedPort(hostPort, devicePort)); 635 return hostPort; 636 } 637 638 @override 639 List<ForwardedPort> get forwardedPorts => _forwardedPorts; 640 final List<ForwardedPort> _forwardedPorts = <ForwardedPort>[]; 641 642 @override 643 Future<void> unforward(ForwardedPort forwardedPort) async { 644 _forwardedPorts.remove(forwardedPort); 645 final Process process = _processes.remove(forwardedPort.hostPort); 646 process?.kill(); 647 final List<String> command = <String>[ 648 'ssh', 649 '-F', 650 fuchsiaArtifacts.sshConfig.absolute.path, 651 '-O', 652 'cancel', 653 '-vvv', 654 '-L', 655 '${forwardedPort.hostPort}:$_ipv4Loopback:${forwardedPort.devicePort}', 656 device.id 657 ]; 658 final ProcessResult result = await processManager.run(command); 659 if (result.exitCode != 0) { 660 throwToolExit(result.stderr); 661 } 662 } 663} 664