// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; import '../application_package.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; import '../base/utils.dart'; import '../build_info.dart'; import '../bundle.dart'; import '../bundle_builder.dart'; import '../convert.dart'; import '../device.dart'; import '../device_port_forwarder.dart'; import '../features.dart'; import '../project.dart'; import '../protocol_discovery.dart'; import 'custom_device_config.dart'; import 'custom_device_workflow.dart'; import 'custom_devices_config.dart'; /// Replace all occurrences of `${someName}` with the value found for that /// name inside replacementValues or additionalReplacementValues. /// /// The replacement value is first looked for in [replacementValues] and then /// [additionalReplacementValues]. If no value is found, an empty string will be /// substituted instead. List<String> interpolateCommand( List<String> command, Map<String, String> replacementValues, { Map<String, String> additionalReplacementValues = const <String, String>{} }) { return interpolateStringList( command, Map<String, String>.of(additionalReplacementValues) ..addAll(replacementValues) ); } /// A log reader that can listen to a process' stdout / stderr or another log line /// Stream. class CustomDeviceLogReader extends DeviceLogReader { CustomDeviceLogReader(this.name); /// The name of the device this log reader is associated with. @override final String name; @visibleForTesting final StreamController<String> logLinesController = StreamController<String>.broadcast(); @visibleForTesting final List<StreamSubscription<String>> subscriptions = <StreamSubscription<String>>[]; /// Listen to [process]' stdout and stderr, decode them using [SystemEncoding] /// and add each decoded line to [logLines]. /// /// However, [logLines] will not be done when the [process]' stdout and stderr /// streams are done. So [logLines] will still be alive after the process has /// finished. /// /// See [CustomDeviceLogReader.dispose] to end the [logLines] stream. void listenToProcessOutput(Process process, {Encoding encoding = systemEncoding}) { final Converter<List<int>, String> decoder = encoding.decoder; subscriptions.add( process.stdout.transform<String>(decoder) .transform<String>(const LineSplitter()) .listen(logLinesController.add), ); subscriptions.add( process.stderr.transform<String>(decoder) .transform<String>(const LineSplitter()) .listen(logLinesController.add) ); } /// Add all lines emitted by [lines] to this [CustomDeviceLogReader]s [logLines] /// stream. /// /// Similar to [listenToProcessOutput], [logLines] will not be marked as done /// when the argument stream is done. /// /// Useful when you want to combine the contents of multiple log readers. void listenToLinesStream(Stream<String> lines) { subscriptions.add( lines.listen(logLinesController.add) ); } /// Dispose this log reader, freeing all associated resources and marking /// [logLines] as done. @override Future<void> dispose() async { final List<Future<void>> futures = <Future<void>>[]; for (final StreamSubscription<String> subscription in subscriptions) { futures.add(subscription.cancel()); } futures.add(logLinesController.close()); await Future.wait(futures); } @override Stream<String> get logLines => logLinesController.stream; } /// A [DevicePortForwarder] that uses commands to forward / unforward a port. class CustomDevicePortForwarder extends DevicePortForwarder { CustomDevicePortForwarder({ required String deviceName, required List<String> forwardPortCommand, required RegExp forwardPortSuccessRegex, this.numTries, required ProcessManager processManager, required Logger logger, Map<String, String> additionalReplacementValues = const <String, String>{} }) : _deviceName = deviceName, _forwardPortCommand = forwardPortCommand, _forwardPortSuccessRegex = forwardPortSuccessRegex, _processManager = processManager, _processUtils = ProcessUtils( processManager: processManager, logger: logger ), _additionalReplacementValues = additionalReplacementValues; final String _deviceName; final List<String> _forwardPortCommand; final RegExp _forwardPortSuccessRegex; final ProcessManager _processManager; final ProcessUtils _processUtils; final int? numTries; final Map<String, String> _additionalReplacementValues; final List<ForwardedPort> _forwardedPorts = <ForwardedPort>[]; @override Future<void> dispose() async { // copy the list so we don't modify it concurrently await Future.wait(List<ForwardedPort>.of(_forwardedPorts).map(unforward)); } Future<ForwardedPort?> tryForward(int devicePort, int hostPort) async { final List<String> interpolated = interpolateCommand( _forwardPortCommand, <String, String>{ 'devicePort': '$devicePort', 'hostPort': '$hostPort', }, additionalReplacementValues: _additionalReplacementValues ); // launch the forwarding command final Process process = await _processUtils.start(interpolated); final Completer<ForwardedPort?> completer = Completer<ForwardedPort?>(); // Read the outputs of the process; if we find a line that matches // the configs forwardPortSuccessRegex, we complete with a successfully // forwarded port. // If that regex never matches, this will potentially run forever // and the forwarding will never complete. final CustomDeviceLogReader reader = CustomDeviceLogReader(_deviceName)..listenToProcessOutput(process); final StreamSubscription<String> logLinesSubscription = reader.logLines.listen((String line) { if (_forwardPortSuccessRegex.hasMatch(line) && !completer.isCompleted) { completer.complete( ForwardedPort.withContext(hostPort, devicePort, process) ); } }); // if the process exits (even with exitCode == 0), that is considered // a port forwarding failure and we complete with a null value. unawaited(process.exitCode.whenComplete(() { if (!completer.isCompleted) { completer.complete(); } })); unawaited(completer.future.whenComplete(() { unawaited(logLinesSubscription.cancel()); unawaited(reader.dispose()); })); return completer.future; } @override Future<int> forward(int devicePort, {int? hostPort}) async { int actualHostPort = (hostPort == 0 || hostPort == null) ? devicePort : hostPort; int tries = 0; while ((numTries == null) || (tries < numTries!)) { // when the desired host port is already forwarded by this Forwarder, // choose another one while (_forwardedPorts.any((ForwardedPort port) => port.hostPort == actualHostPort)) { actualHostPort += 1; } final ForwardedPort? port = await tryForward(devicePort, actualHostPort); if (port != null) { _forwardedPorts.add(port); return actualHostPort; } else { // null value means the forwarding failed (for whatever reason) // increase port by one and try again actualHostPort += 1; tries += 1; } } throw ToolExit('Forwarding port for custom device $_deviceName failed after $tries tries.'); } @override List<ForwardedPort> get forwardedPorts => List<ForwardedPort>.unmodifiable(_forwardedPorts); @override Future<void> unforward(ForwardedPort forwardedPort) async { assert(_forwardedPorts.contains(forwardedPort)); // since a forwarded port represents a running process launched with // the forwardPortCommand, unforwarding is as easy as killing the process final int? pid = forwardedPort.context?.pid; if (pid != null) { _processManager.killPid(pid); } _forwardedPorts.remove(forwardedPort); } } /// A combination of [ApplicationPackage] and a [CustomDevice]. Can only start, /// stop this specific app package with this specific device. Useful because we /// often need to store additional context to an app that is running on a device, /// like any forwarded ports we need to unforward later, the process we need to /// kill to stop the app, maybe other things in the future. class CustomDeviceAppSession { CustomDeviceAppSession({ required this.name, required CustomDevice device, required ApplicationPackage appPackage, required Logger logger, required ProcessManager processManager }) : _appPackage = appPackage, _device = device, _logger = logger, _processManager = processManager, _processUtils = ProcessUtils( processManager: processManager, logger: logger ), logReader = CustomDeviceLogReader(name); final String name; final CustomDevice _device; final ApplicationPackage _appPackage; final Logger _logger; final ProcessManager _processManager; final ProcessUtils _processUtils; final CustomDeviceLogReader logReader; Process? _process; int? _forwardedHostPort; /// Get the engine options for the given [debuggingOptions], /// [traceStartup] and [route]. /// /// [debuggingOptions] and [route] can be null. /// /// For example, `_getEngineOptions(null, false, null)` will return /// `['enable-dart-profiling=true']` List<String> _getEngineOptions(DebuggingOptions debuggingOptions, bool traceStartup, String? route) { final String dartVmFlags = computeDartVmFlags(debuggingOptions); return <String>[ if (traceStartup) 'trace-startup=true', if (route != null) 'route=$route', if (debuggingOptions.enableDartProfiling) 'enable-dart-profiling=true', if (debuggingOptions.enableSoftwareRendering) 'enable-software-rendering=true', if (debuggingOptions.skiaDeterministicRendering) 'skia-deterministic-rendering=true', if (debuggingOptions.traceSkia) 'trace-skia=true', if (debuggingOptions.traceAllowlist != null) 'trace-allowlist=${debuggingOptions.traceAllowlist}', if (debuggingOptions.traceSystrace) 'trace-systrace=true', if (debuggingOptions.traceToFile != null) 'trace-to-file=${debuggingOptions.traceToFile}', if (debuggingOptions.endlessTraceBuffer) 'endless-trace-buffer=true', if (debuggingOptions.dumpSkpOnShaderCompilation) 'dump-skp-on-shader-compilation=true', if (debuggingOptions.cacheSkSL) 'cache-sksl=true', if (debuggingOptions.purgePersistentCache) 'purge-persistent-cache=true', if (debuggingOptions.debuggingEnabled) ...<String>[ if (debuggingOptions.deviceVmServicePort != null) 'vm-service-port=${debuggingOptions.deviceVmServicePort}', if (debuggingOptions.buildInfo.isDebug) ...<String>[ 'enable-checked-mode=true', 'verify-entry-points=true', ], if (debuggingOptions.startPaused) 'start-paused=true', if (debuggingOptions.disableServiceAuthCodes) 'disable-service-auth-codes=true', if (dartVmFlags.isNotEmpty) 'dart-flags=$dartVmFlags', if (debuggingOptions.useTestFonts) 'use-test-fonts=true', if (debuggingOptions.verboseSystemLogs) 'verbose-logging=true', ], ]; } /// Get the engine options for the given [debuggingOptions], /// [traceStartup] and [route]. /// /// [debuggingOptions] and [route] can be null. /// /// For example, `_getEngineOptionsForCmdline(null, false, null)` will return /// `--enable-dart-profiling=true` String _getEngineOptionsForCmdline(DebuggingOptions debuggingOptions, bool traceStartup, String? route) { return _getEngineOptions(debuggingOptions, traceStartup, route).map((String e) => '--$e').join(' '); } /// Start the app on the device. /// Needs the app to be installed on the device and not running already. /// /// [mainPath], [route], [debuggingOptions], [platformArgs] and /// [userIdentifier] may be null. /// /// [ipv6] may not be respected since it depends on the device config whether /// it uses ipv6 or ipv4 Future<LaunchResult> start({ String? mainPath, String? route, required DebuggingOptions debuggingOptions, Map<String, Object?> platformArgs = const <String, Object>{}, bool prebuiltApplication = false, bool ipv6 = false, String? userIdentifier }) async { final bool traceStartup = platformArgs['trace-startup'] as bool? ?? false; final String? packageName = _appPackage.name; if (packageName == null) { throw ToolExit('Could not start app, name for $_appPackage is unknown.'); } final List<String> interpolated = interpolateCommand( _device._config.runDebugCommand, <String, String>{ 'remotePath': '/tmp/', 'appName': packageName, 'engineOptions': _getEngineOptionsForCmdline(debuggingOptions, traceStartup, route), } ); final Process process = await _processUtils.start(interpolated); assert(_process == null); _process = process; final ProtocolDiscovery discovery = ProtocolDiscovery.vmService( logReader, portForwarder: _device._config.usesPortForwarding ? _device.portForwarder : null, logger: _logger, ipv6: ipv6, ); // We need to make the discovery listen to the logReader before the logReader // listens to the process output since logReader.lines is a broadcast stream // and events may be discarded. // Whether that actually happens is another thing since this is all executed // in the same microtask AFAICT but this way we're on the safe side. logReader.listenToProcessOutput(process); final Uri? vmServiceUri = await discovery.uri; await discovery.cancel(); if (_device._config.usesPortForwarding) { _forwardedHostPort = vmServiceUri?.port; } return LaunchResult.succeeded(vmServiceUri: vmServiceUri); } void _maybeUnforwardPort() { if (_forwardedHostPort != null) { final ForwardedPort forwardedPort = _device.portForwarder.forwardedPorts.singleWhere((ForwardedPort forwardedPort) { return forwardedPort.hostPort == _forwardedHostPort; }); _forwardedHostPort = null; _device.portForwarder.unforward(forwardedPort); } } /// Stop the app on the device. /// Returns false if the app is not yet running. Also unforwards any /// forwarded ports. Future<bool> stop() async { if (_process == null) { return false; } _maybeUnforwardPort(); final bool result = _processManager.killPid(_process!.pid); _process = null; return result; } void dispose() { if (_process != null) { _maybeUnforwardPort(); _processManager.killPid(_process!.pid); _process = null; } unawaited(logReader.dispose()); } } /// A device that uses user-configured actions for the common device methods. /// The exact actions are defined by the contents of the [CustomDeviceConfig] /// used to construct it. class CustomDevice extends Device { CustomDevice({ required CustomDeviceConfig config, required Logger logger, required ProcessManager processManager, }) : _config = config, _logger = logger, _processManager = processManager, _processUtils = ProcessUtils( processManager: processManager, logger: logger ), _globalLogReader = CustomDeviceLogReader(config.label), portForwarder = config.usesPortForwarding ? CustomDevicePortForwarder( deviceName: config.label, forwardPortCommand: config.forwardPortCommand!, forwardPortSuccessRegex: config.forwardPortSuccessRegex!, processManager: processManager, logger: logger, ) : const NoOpDevicePortForwarder(), super( config.id, category: Category.mobile, ephemeral: true, platformType: PlatformType.custom ); final CustomDeviceConfig _config; final Logger _logger; final ProcessManager _processManager; final ProcessUtils _processUtils; final Map<ApplicationPackage, CustomDeviceAppSession> _sessions = <ApplicationPackage, CustomDeviceAppSession>{}; final CustomDeviceLogReader _globalLogReader; @override final DevicePortForwarder portForwarder; CustomDeviceAppSession _getOrCreateAppSession(ApplicationPackage app) { return _sessions.putIfAbsent( app, () { /// create a new session and add its logging to the global log reader. /// (needed bc it's possible the infra requests a global log in [getLogReader] final CustomDeviceAppSession session = CustomDeviceAppSession( name: name, device: this, appPackage: app, logger: _logger, processManager: _processManager ); _globalLogReader.listenToLinesStream(session.logReader.logLines); return session; } ); } /// Tries to ping the device using the ping command given in the config. /// All string interpolation occurrences inside the ping command will be replaced /// using the entries in [replacementValues]. /// /// If the process finishes with an exit code != 0, false will be returned and /// the error (with the process' stdout and stderr) will be logged using /// [_logger.printError]. /// /// If [timeout] is not null and the process doesn't finish in time, /// it will be killed with a SIGTERM, false will be returned and the timeout /// will be reported in the log using [_logger.printError]. If [timeout] /// is null, it's treated as if it's an infinite timeout. Future<bool> tryPing({ Duration? timeout, Map<String, String> replacementValues = const <String, String>{} }) async { final List<String> interpolated = interpolateCommand( _config.pingCommand, replacementValues ); final RunResult result = await _processUtils.run( interpolated, timeout: timeout ); if (result.exitCode != 0) { return false; } // If the user doesn't configure a ping success regex, any ping with exitCode zero // is good enough. Otherwise we check if either stdout or stderr have a match of // the pingSuccessRegex. final RegExp? pingSuccessRegex = _config.pingSuccessRegex; return pingSuccessRegex == null || pingSuccessRegex.hasMatch(result.stdout) || pingSuccessRegex.hasMatch(result.stderr); } /// Tries to execute the configs postBuild command using [appName] for the /// `${appName}` and [localPath] for the `${localPath}` interpolations, /// any additional string interpolation occurrences will be replaced using the /// entries in [additionalReplacementValues]. /// /// Calling this when the config doesn't have a configured postBuild command /// is an error. /// /// If [timeout] is not null and the process doesn't finish in time, it /// will be killed with a SIGTERM, false will be returned and the timeout /// will be reported in the log using [_logger.printError]. If [timeout] /// is null, it's treated as if it's an infinite timeout. Future<bool> _tryPostBuild({ required String appName, required String localPath, Duration? timeout, Map<String, String> additionalReplacementValues = const <String, String>{} }) async { assert(_config.postBuildCommand != null); final List<String> interpolated = interpolateCommand( _config.postBuildCommand!, <String, String>{ 'appName': appName, 'localPath': localPath, }, additionalReplacementValues: additionalReplacementValues ); try { await _processUtils.run( interpolated, throwOnError: true, timeout: timeout ); return true; } on ProcessException catch (e) { _logger.printError('Error executing postBuild command for custom device $id: $e'); return false; } } /// Tries to execute the configs uninstall command. /// /// [appName] is the name of the app to be installed. /// /// If [timeout] is not null and the process doesn't finish in time, it /// will be killed with a SIGTERM, false will be returned and the timeout /// will be reported in the log using [_logger.printError]. If [timeout] /// is null, it's treated as if it's an infinite timeout. Future<bool> tryUninstall({ required String appName, Duration? timeout, Map<String, String> additionalReplacementValues = const <String, String>{} }) async { final List<String> interpolated = interpolateCommand( _config.uninstallCommand, <String, String>{ 'appName': appName, }, additionalReplacementValues: additionalReplacementValues ); try { await _processUtils.run( interpolated, throwOnError: true, timeout: timeout ); return true; } on ProcessException catch (e) { _logger.printError('Error executing uninstall command for custom device $id: $e'); return false; } } /// Tries to install the app to the custom device. /// /// [localPath] is the file / directory on the local device that will be /// copied over to the target custom device. This is substituted for any occurrence /// of `${localPath}` in the custom device configs `install` command. /// /// [appName] is the name of the app to be installed. Substituted for any occurrence /// of `${appName}` in the custom device configs `install` command. Future<bool> tryInstall({ required String localPath, required String appName, Duration? timeout, Map<String, String> additionalReplacementValues = const <String, String>{} }) async { final List<String> interpolated = interpolateCommand( _config.installCommand, <String, String>{ 'localPath': localPath, 'appName': appName, }, additionalReplacementValues: additionalReplacementValues ); try { await _processUtils.run( interpolated, throwOnError: true, timeout: timeout ); return true; } on ProcessException catch (e) { _logger.printError('Error executing install command for custom device $id: $e'); return false; } } @override void clearLogs() {} @override Future<void> dispose() async { _sessions ..forEach((_, CustomDeviceAppSession session) => session.dispose()) ..clear(); } @override Future<String?> get emulatorId async => null; @override FutureOr<DeviceLogReader> getLogReader({ ApplicationPackage? app, bool includePastLogs = false }) { if (app != null) { return _getOrCreateAppSession(app).logReader; } return _globalLogReader; } @override Future<bool> installApp(ApplicationPackage app, {String? userIdentifier}) async { final String? appName = app.name; if (appName == null || !await tryUninstall(appName: appName)) { return false; } final bool result = await tryInstall( localPath: getAssetBuildDirectory(), appName: appName, ); return result; } @override Future<bool> isAppInstalled(ApplicationPackage app, {String? userIdentifier}) async { return false; } @override Future<bool> isLatestBuildInstalled(ApplicationPackage app) async { return false; } @override Future<bool> get isLocalEmulator async => false; @override bool get supportsScreenshot => _config.supportsScreenshotting; @override Future<void> takeScreenshot(File outputFile) async { if (!supportsScreenshot) { throw UnsupportedError('Screenshotting is not supported for this device.'); } final List<String> interpolated = interpolateCommand( _config.screenshotCommand!, <String, String>{}, ); final RunResult result = await _processUtils.run(interpolated, throwOnError: true); await outputFile.writeAsBytes(base64Decode(result.stdout)); } @override bool isSupported() { return true; } @override bool isSupportedForProject(FlutterProject flutterProject) { return true; } @override FutureOr<bool> supportsRuntimeMode(BuildMode buildMode) { return buildMode == BuildMode.debug; } @override String get name => _config.label; @override Future<String> get sdkNameAndVersion => Future<String>.value(_config.sdkNameAndVersion); @override Future<LaunchResult> startApp( ApplicationPackage package, { String? mainPath, String? route, required DebuggingOptions debuggingOptions, Map<String, Object?> platformArgs = const <String, Object>{}, bool prebuiltApplication = false, bool ipv6 = false, String? userIdentifier, BundleBuilder? bundleBuilder, }) async { if (!prebuiltApplication) { final String assetBundleDir = getAssetBuildDirectory(); bundleBuilder ??= BundleBuilder(); // this just builds the asset bundle, it's the same as `flutter build bundle` await bundleBuilder.build( platform: await targetPlatform, buildInfo: debuggingOptions.buildInfo, mainPath: mainPath, depfilePath: defaultDepfilePath, assetDirPath: assetBundleDir, ); // if we have a post build step (needed for some embedders), execute it if (_config.postBuildCommand != null) { final String? packageName = package.name; if (packageName == null) { throw ToolExit('Could not start app, name for $package is unknown.'); } await _tryPostBuild( appName: packageName, localPath: assetBundleDir, ); } } // install the app on the device // (will invoke the uninstall and then the install command internally) await installApp(package, userIdentifier: userIdentifier); // finally launch the app return _getOrCreateAppSession(package).start( mainPath: mainPath, route: route, debuggingOptions: debuggingOptions, platformArgs: platformArgs, prebuiltApplication: prebuiltApplication, ipv6: ipv6, userIdentifier: userIdentifier, ); } @override Future<bool> stopApp(ApplicationPackage? app, {String? userIdentifier}) async { if (app == null) { return false; } return _getOrCreateAppSession(app).stop(); } @override Future<TargetPlatform> get targetPlatform async => _config.platform ?? TargetPlatform.linux_arm64; @override Future<bool> uninstallApp(ApplicationPackage app, {String? userIdentifier}) async { final String? appName = app.name; if (appName == null) { return false; } return tryUninstall(appName: appName); } } /// A [PollingDeviceDiscovery] that'll try to ping all enabled devices in the argument /// [CustomDevicesConfig] and report the ones that were actually reachable. class CustomDevices extends PollingDeviceDiscovery { /// Create a custom device discovery that pings all enabled devices in the /// given [CustomDevicesConfig]. CustomDevices({ required FeatureFlags featureFlags, required ProcessManager processManager, required Logger logger, required CustomDevicesConfig config }) : _customDeviceWorkflow = CustomDeviceWorkflow( featureFlags: featureFlags, ), _logger = logger, _processManager = processManager, _config = config, super('custom devices'); final CustomDeviceWorkflow _customDeviceWorkflow; final ProcessManager _processManager; final Logger _logger; final CustomDevicesConfig _config; @override bool get supportsPlatform => true; @override bool get canListAnything => _customDeviceWorkflow.canListDevices; CustomDevicesConfig get _customDevicesConfig => _config; List<CustomDevice> get _enabledCustomDevices { return _customDevicesConfig.tryGetDevices() .where((CustomDeviceConfig element) => element.enabled) .map( (CustomDeviceConfig config) => CustomDevice( config: config, logger: _logger, processManager: _processManager ) ).toList(); } @override Future<List<Device>> pollingGetDevices({Duration? timeout}) async { if (!canListAnything) { return const <Device>[]; } final List<CustomDevice> devices = _enabledCustomDevices; // maps any custom device to whether its reachable or not. final Map<CustomDevice, bool> pingedDevices = Map<CustomDevice, bool>.fromIterables( devices, await Future.wait(devices.map((CustomDevice e) => e.tryPing(timeout: timeout))) ); // remove all the devices we couldn't reach. pingedDevices.removeWhere((_, bool value) => !value); // return only the devices. return pingedDevices.keys.toList(); } @override Future<List<String>> getDiagnostics() async => const <String>[]; @override List<String> get wellKnownIds => const <String>[]; }