| // 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:async/async.dart'; | |
| import 'package:meta/meta.dart'; | |
| import 'package:process/process.dart'; | |
| import '../base/common.dart'; | |
| import '../base/error_handling_io.dart'; | |
| import '../base/file_system.dart'; | |
| import '../base/io.dart'; | |
| import '../base/logger.dart'; | |
| import '../base/platform.dart'; | |
| import '../base/terminal.dart'; | |
| import '../context/tool_context.dart'; | |
| import '../convert.dart'; | |
| import '../custom_devices/custom_device.dart'; | |
| import '../custom_devices/custom_device_config.dart'; | |
| import '../custom_devices/custom_devices_config.dart'; | |
| import '../device_port_forwarder.dart'; | |
| import '../features.dart'; | |
| import '../runner/flutter_command.dart'; | |
| import '../runner/flutter_command_runner.dart'; | |
| class CustomDevicesCommand extends FlutterCommand { | |
| CustomDevicesCommand({required FeatureFlags featureFlags, required ToolContext toolContext}) | |
| : _featureFlags = featureFlags, | |
| super(toolContext: toolContext) { | |
| addSubcommand(CustomDevicesListCommand(featureFlags: featureFlags, toolContext: toolContext)); | |
| addSubcommand(CustomDevicesResetCommand(featureFlags: featureFlags, toolContext: toolContext)); | |
| addSubcommand(CustomDevicesAddCommand(featureFlags: featureFlags, toolContext: toolContext)); | |
| addSubcommand(CustomDevicesDeleteCommand(featureFlags: featureFlags, toolContext: toolContext)); | |
| } | |
| final FeatureFlags _featureFlags; | |
| @override | |
| ToolContext get toolContext => super.toolContext!; | |
| @override | |
| String get description { | |
| String configFileLine; | |
| if (_featureFlags.areCustomDevicesEnabled) { | |
| configFileLine = | |
| '\nMakes changes to the config file at "${toolContext.customDevicesConfig.configPath}".\n'; | |
| } else { | |
| configFileLine = ''; | |
| } | |
| return ''' | |
| List, reset, add and delete custom devices. | |
| $configFileLine | |
| This is just a collection of commonly used shorthands for things like adding | |
| ssh devices, resetting (with backup) and checking the config file. For advanced | |
| configuration or more complete documentation, edit the config file with an | |
| editor that supports JSON schemas like VS Code. | |
| Requires the custom devices feature to be enabled. You can enable it using "flutter config --enable-custom-devices". | |
| '''; | |
| } | |
| @override | |
| String get name => 'custom-devices'; | |
| @override | |
| String get category => FlutterCommandCategory.tools; | |
| @override | |
| Future<FlutterCommandResult> runCommand() async { | |
| return FlutterCommandResult.success(); | |
| } | |
| } | |
| /// This class is meant to provide some commonly used utility functions | |
| /// to the subcommands, like backing up the config file & checking if the | |
| /// feature is enabled. | |
| abstract class CustomDevicesCommandBase extends FlutterCommand { | |
| CustomDevicesCommandBase({required this.featureFlags, required ToolContext toolContext}) | |
| : super(toolContext: toolContext); | |
| @override | |
| ToolContext get toolContext => super.toolContext!; | |
| @protected | |
| CustomDevicesConfig get customDevicesConfig => toolContext.customDevicesConfig; | |
| @protected | |
| final FeatureFlags featureFlags; | |
| @protected | |
| FileSystem get fileSystem => toolContext.fs; | |
| @protected | |
| Logger get logger => toolContext.logger; | |
| /// The path to the (potentially non-existing) backup of the config file. | |
| @protected | |
| String get configBackupPath => '${customDevicesConfig.configPath}.bak'; | |
| /// Copies the current config file to [configBackupPath], overwriting it | |
| /// if necessary. Returns false and does nothing if the current config file | |
| /// doesn't exist. (True otherwise) | |
| @protected | |
| bool backup() { | |
| final File configFile = fileSystem.file(customDevicesConfig.configPath); | |
| if (configFile.existsSync()) { | |
| configFile.copySync(configBackupPath); | |
| return true; | |
| } | |
| return false; | |
| } | |
| /// Checks if the custom devices feature is enabled and returns true/false | |
| /// accordingly. Additionally, logs an error if it's not enabled with a hint | |
| /// on how to enable it. | |
| @protected | |
| void checkFeatureEnabled() { | |
| if (!featureFlags.areCustomDevicesEnabled) { | |
| throwToolExit( | |
| 'Custom devices feature must be enabled. ' | |
| 'Enable using `flutter config --enable-custom-devices`.', | |
| ); | |
| } | |
| } | |
| } | |
| class CustomDevicesListCommand extends CustomDevicesCommandBase { | |
| CustomDevicesListCommand({required super.featureFlags, required super.toolContext}); | |
| @override | |
| String get description => ''' | |
| List the currently configured custom devices, both enabled and disabled, reachable or not. | |
| '''; | |
| @override | |
| String get name => 'list'; | |
| @override | |
| Future<FlutterCommandResult> runCommand() async { | |
| checkFeatureEnabled(); | |
| late List<CustomDeviceConfig> devices; | |
| try { | |
| devices = customDevicesConfig.devices; | |
| } on Exception { | |
| throwToolExit('Could not list custom devices.'); | |
| } | |
| final String configPath = customDevicesConfig.configPath; | |
| if (devices.isEmpty) { | |
| logger.printStatus('No custom devices found in "$configPath"'); | |
| } else { | |
| logger.printStatus('List of custom devices in "$configPath":'); | |
| for (final device in devices) { | |
| logger.printStatus( | |
| 'id: ${device.id}, label: ${device.label}, enabled: ${device.enabled}', | |
| indent: 2, | |
| hangingIndent: 2, | |
| ); | |
| } | |
| } | |
| return FlutterCommandResult.success(); | |
| } | |
| } | |
| class CustomDevicesResetCommand extends CustomDevicesCommandBase { | |
| CustomDevicesResetCommand({required super.featureFlags, required super.toolContext}); | |
| @override | |
| String get description => ''' | |
| Reset the config file to the default. | |
| The current config file will be backed up to the same path, but with a `.bak` appended. | |
| If a file already exists at the backup location, it will be overwritten. | |
| '''; | |
| @override | |
| String get name => 'reset'; | |
| @override | |
| Future<FlutterCommandResult> runCommand() async { | |
| checkFeatureEnabled(); | |
| final bool wasBackedUp = backup(); | |
| ErrorHandlingFileSystem.deleteIfExists(fileSystem.file(customDevicesConfig.configPath)); | |
| customDevicesConfig.ensureFileExists(); | |
| logger.printStatus( | |
| wasBackedUp | |
| ? 'Successfully reset the custom devices config file and created a ' | |
| 'backup at "$configBackupPath".' | |
| : 'Successfully reset the custom devices config file.', | |
| ); | |
| return FlutterCommandResult.success(); | |
| } | |
| } | |
| class CustomDevicesAddCommand extends CustomDevicesCommandBase { | |
| CustomDevicesAddCommand({required super.featureFlags, required super.toolContext}) { | |
| argParser.addFlag( | |
| _kCheck, | |
| help: | |
| 'Make sure the config actually works. This will execute some of the ' | |
| 'commands in the config (if necessary with dummy arguments). This ' | |
| 'flag is enabled by default when "--json" is not specified. If ' | |
| '"--json" is given, it is disabled by default.\n' | |
| 'For example, a config with "null" as the "runDebug" command is ' | |
| 'invalid. If the "runDebug" command is valid (so it is an array of ' | |
| 'strings) but the command is not found (because you have a typo, for ' | |
| 'example), the config won\'t work and "--check" will spot that.', | |
| ); | |
| argParser.addOption( | |
| _kJson, | |
| help: | |
| 'Add the custom device described by this JSON-encoded string to the ' | |
| 'list of custom-devices instead of using the normal, interactive way ' | |
| 'of configuring. Useful if you want to use the "flutter custom-devices ' | |
| 'add" command from a script, or use it non-interactively for some ' | |
| 'other reason.\n' | |
| "By default, this won't check whether the passed in config actually " | |
| 'works. For more info see the "--check" option.', | |
| valueHelp: '{"id": "pi", ...}', | |
| aliases: _kJsonAliases, | |
| ); | |
| argParser.addFlag( | |
| _kSsh, | |
| help: | |
| 'Add a ssh-device. This will automatically fill out some of the config ' | |
| 'options for you with good defaults, and in other cases save you some ' | |
| "typing. So you'll only need to enter some things like hostname and " | |
| 'username of the remote device instead of entering each individual ' | |
| 'command.', | |
| defaultsTo: true, | |
| negatable: false, | |
| ); | |
| } | |
| static const _kJson = 'json'; | |
| static const _kJsonAliases = <String>['js']; | |
| static const _kCheck = 'check'; | |
| static const _kSsh = 'ssh'; | |
| // A hostname consists of one or more "names", separated by a dot. | |
| // A name may consist of alpha-numeric characters. Hyphens are also allowed, | |
| // but not as the first or last character of the name. | |
| static final _hostnameRegex = RegExp( | |
| r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$', | |
| ); | |
| late StreamQueue<String> inputs; | |
| @override | |
| String get description => 'Add a new device the custom devices config file.'; | |
| @override | |
| String get name => 'add'; | |
| void _printConfigCheckingError(String err) { | |
| logger.printError(err); | |
| } | |
| /// Check this config by executing some of the commands, see if they run | |
| /// fine. | |
| Future<bool> _checkConfigWithLogging(CustomDeviceConfig config) async { | |
| final ProcessManager pm = toolContext.processManager; | |
| final device = CustomDevice(config: config, logger: logger, processManager: pm); | |
| var result = true; | |
| try { | |
| final bool reachable = await device.tryPing(); | |
| if (!reachable) { | |
| _printConfigCheckingError("Couldn't ping device."); | |
| result = false; | |
| } | |
| } on Exception catch (e) { | |
| _printConfigCheckingError('While executing ping command: $e'); | |
| result = false; | |
| } | |
| final Directory temp = await fileSystem.systemTempDirectory.createTemp(); | |
| try { | |
| final bool ok = await device.tryInstall(localPath: temp.path, appName: temp.basename); | |
| if (!ok) { | |
| _printConfigCheckingError("Couldn't install test app on device."); | |
| result = false; | |
| } | |
| } on Exception catch (e) { | |
| _printConfigCheckingError('While executing install command: $e'); | |
| result = false; | |
| } | |
| await temp.delete(); | |
| try { | |
| final bool ok = await device.tryUninstall(appName: temp.basename); | |
| if (!ok) { | |
| _printConfigCheckingError("Couldn't uninstall test app from device."); | |
| result = false; | |
| } | |
| } on Exception catch (e) { | |
| _printConfigCheckingError('While executing uninstall command: $e'); | |
| result = false; | |
| } | |
| if (config.usesPortForwarding) { | |
| final portForwarder = CustomDevicePortForwarder( | |
| deviceName: device.displayName, | |
| forwardPortCommand: config.forwardPortCommand!, | |
| forwardPortSuccessRegex: config.forwardPortSuccessRegex!, | |
| processManager: pm, | |
| logger: logger, | |
| ); | |
| try { | |
| // find a random port we can forward | |
| final int port = await toolContext.os.findFreePort(); | |
| final ForwardedPort? forwardedPort = await portForwarder.tryForward(port, port); | |
| if (forwardedPort == null) { | |
| _printConfigCheckingError("Couldn't forward test port $port from device."); | |
| result = false; | |
| } else { | |
| await portForwarder.unforward(forwardedPort); | |
| } | |
| } on Exception catch (e) { | |
| _printConfigCheckingError('While forwarding/unforwarding device port: $e'); | |
| result = false; | |
| } | |
| } | |
| if (result) { | |
| logger.printStatus('Passed all checks successfully.'); | |
| } | |
| return result; | |
| } | |
| /// Run non-interactively (useful if running from scripts or bots), | |
| /// add value of the `--json` arg to the config. | |
| /// | |
| /// Only check if `--check` is explicitly specified. (Don't check by default) | |
| Future<FlutterCommandResult> runNonInteractively() async { | |
| final String jsonStr = stringArg(_kJson)!; | |
| final bool shouldCheck = boolArg(_kCheck); | |
| dynamic json; | |
| try { | |
| json = jsonDecode(jsonStr); | |
| } on FormatException catch (e) { | |
| throwToolExit('Could not decode json: $e'); | |
| } | |
| late CustomDeviceConfig config; | |
| try { | |
| config = CustomDeviceConfig.fromJson(json); | |
| } on CustomDeviceRevivalException catch (e) { | |
| throwToolExit('Invalid custom device config: $e'); | |
| } | |
| if (shouldCheck && !await _checkConfigWithLogging(config)) { | |
| throwToolExit("Custom device didn't pass all checks."); | |
| } | |
| customDevicesConfig.add(config); | |
| printSuccessfullyAdded(); | |
| return FlutterCommandResult.success(); | |
| } | |
| void printSuccessfullyAdded() { | |
| logger.printStatus( | |
| 'Successfully added custom device to config file at "${customDevicesConfig.configPath}".', | |
| ); | |
| } | |
| bool _isValidHostname(String s) => _hostnameRegex.hasMatch(s); | |
| bool _isValidIpAddr(String s) => InternetAddress.tryParse(s) != null; | |
| /// Ask the user to input a string. | |
| Future<String?> askForString( | |
| String name, { | |
| String? description, | |
| String? example, | |
| String? defaultsTo, | |
| Future<bool> Function(String)? validator, | |
| }) async { | |
| String msg = description ?? name; | |
| final String exampleOrDefault = <String>[ | |
| if (example != null) 'example: $example', | |
| if (defaultsTo != null) 'empty for $defaultsTo', | |
| ].join(', '); | |
| if (exampleOrDefault.isNotEmpty) { | |
| msg += ' ($exampleOrDefault)'; | |
| } | |
| logger.printStatus(msg); | |
| while (true) { | |
| if (!await inputs.hasNext) { | |
| return null; | |
| } | |
| final String input = await inputs.next; | |
| if (validator != null && !await validator(input)) { | |
| logger.printStatus('Invalid input. Please enter $name:'); | |
| } else { | |
| return input; | |
| } | |
| } | |
| } | |
| /// Ask the user for a y(es) / n(o) or empty input. | |
| Future<bool> askForBool(String name, {String? description, bool defaultsTo = true}) async { | |
| final defaultsToStr = defaultsTo ? '[Y/n]' : '[y/N]'; | |
| logger.printStatus('$description $defaultsToStr (empty for default)'); | |
| while (true) { | |
| final String input = await inputs.next; | |
| if (input.isEmpty) { | |
| return defaultsTo; | |
| } else if (input.toLowerCase() == 'y') { | |
| return true; | |
| } else if (input.toLowerCase() == 'n') { | |
| return false; | |
| } else { | |
| logger.printStatus( | |
| 'Invalid input. Expected is either y, n or empty for default. $name? $defaultsToStr', | |
| ); | |
| } | |
| } | |
| } | |
| /// Ask the user if he wants to apply the config. | |
| /// Shows a different prompt if errors or warnings exist in the config. | |
| Future<bool> askApplyConfig({bool hasErrorsOrWarnings = false}) { | |
| return askForBool( | |
| 'apply', | |
| description: hasErrorsOrWarnings | |
| ? 'Warnings or errors exist in custom device. ' | |
| 'Would you like to add the custom device to the config anyway?' | |
| : 'Would you like to add the custom device to the config now?', | |
| defaultsTo: !hasErrorsOrWarnings, | |
| ); | |
| } | |
| /// Run interactively (with user prompts), the target device should be | |
| /// connected to via ssh. | |
| Future<FlutterCommandResult> runInteractivelySsh() async { | |
| final bool shouldCheck = boolArg(_kCheck); | |
| // Listen to the keystrokes stream as late as possible, since it's a | |
| // single-subscription stream apparently. | |
| // Also, _terminal.keystrokes can be closed unexpectedly, which will result | |
| // in StreamQueue.next throwing a StateError when make the StreamQueue listen | |
| // to that directly. | |
| // This caused errors when using Ctrl+C to terminate while the | |
| // custom-devices add command is waiting for user input. | |
| // So instead, we add the keystrokes stream events to a new single-subscription | |
| // stream and listen to that instead. | |
| final nonClosingKeystrokes = StreamController<String>(); | |
| final Terminal terminal = toolContext.terminal; | |
| final StreamSubscription<String> keystrokesSubscription = terminal.keystrokes.listen( | |
| (String s) => nonClosingKeystrokes.add(s.trim()), | |
| cancelOnError: true, | |
| ); | |
| inputs = StreamQueue<String>(nonClosingKeystrokes.stream); | |
| final String id = (await askForString( | |
| 'id', | |
| description: | |
| 'Please enter the id you want to device to have. Must contain only ' | |
| 'alphanumeric or underscore characters.', | |
| example: 'pi', | |
| validator: (String s) async => RegExp(r'^\w+$').hasMatch(s), | |
| ))!; | |
| final String label = (await askForString( | |
| 'label', | |
| description: | |
| 'Please enter the label of the device, which is a slightly more verbose ' | |
| 'name for the device.', | |
| example: 'Raspberry Pi', | |
| ))!; | |
| final String sdkNameAndVersion = (await askForString( | |
| 'SDK name and version', | |
| example: 'Raspberry Pi 4 Model B+', | |
| ))!; | |
| final bool enabled = await askForBool('enabled', description: 'Should the device be enabled?'); | |
| final String targetStr = (await askForString( | |
| 'target', | |
| description: 'Please enter the hostname or IPv4/v6 address of the device.', | |
| example: 'raspberrypi', | |
| validator: (String s) async => _isValidHostname(s) || _isValidIpAddr(s), | |
| ))!; | |
| final InternetAddress? targetIp = InternetAddress.tryParse(targetStr); | |
| final useIp = targetIp != null; | |
| final bool ipv6 = useIp && targetIp.type == InternetAddressType.IPv6; | |
| final InternetAddress loopbackIp = ipv6 | |
| ? InternetAddress.loopbackIPv6 | |
| : InternetAddress.loopbackIPv4; | |
| final String username = (await askForString( | |
| 'username', | |
| description: 'Please enter the username used for ssh-ing into the remote device.', | |
| example: 'pi', | |
| defaultsTo: 'no username', | |
| ))!; | |
| final String remoteRunDebugCommand = (await askForString( | |
| 'run command', | |
| description: | |
| 'Please enter the command executed on the remote device for starting ' | |
| r'the app. "/tmp/${appName}" is the path to the asset bundle.', | |
| example: r'flutter-pi /tmp/${appName}', | |
| ))!; | |
| final bool usePortForwarding = await askForBool( | |
| 'use port forwarding', | |
| description: | |
| 'Should the device use port forwarding? ' | |
| 'Using port forwarding is the default because it works in all cases, however if your ' | |
| 'remote device has a static IP address and you have a way of ' | |
| 'specifying the "--vm-service-host=<ip>" engine option, you might prefer ' | |
| 'not using port forwarding.', | |
| ); | |
| final String screenshotCommand = (await askForString( | |
| 'screenshot command', | |
| description: 'Enter the command executed on the remote device for taking a screenshot.', | |
| example: r"fbgrab /tmp/screenshot.png && cat /tmp/screenshot.png | base64 | tr -d ' \n\t'", | |
| defaultsTo: 'no screenshotting support', | |
| ))!; | |
| // SSH expects IPv6 addresses to use the bracket syntax like URIs do too, | |
| // but the IPv6 the user enters is a raw IPv6 address, so we need to wrap it. | |
| final String sshTarget = | |
| (username.isNotEmpty ? '$username@' : '') + (ipv6 ? '[${targetIp.address}]' : targetStr); | |
| final String formattedLoopbackIp = ipv6 ? '[${loopbackIp.address}]' : loopbackIp.address; | |
| var config = CustomDeviceConfig( | |
| id: id, | |
| label: label, | |
| sdkNameAndVersion: sdkNameAndVersion, | |
| enabled: enabled, | |
| // host-platform specific, filled out later | |
| pingCommand: const <String>[], | |
| postBuildCommand: const <String>[], | |
| // just install to /tmp/${appName} by default | |
| installCommand: <String>[ | |
| 'scp', | |
| '-r', | |
| '-o', | |
| 'BatchMode=yes', | |
| if (ipv6) '-6', | |
| r'${localPath}', | |
| '$sshTarget:/tmp/\${appName}', | |
| ], | |
| uninstallCommand: <String>[ | |
| 'ssh', | |
| '-o', | |
| 'BatchMode=yes', | |
| if (ipv6) '-6', | |
| sshTarget, | |
| r'rm -rf "/tmp/${appName}"', | |
| ], | |
| runDebugCommand: <String>[ | |
| 'ssh', | |
| '-o', | |
| 'BatchMode=yes', | |
| if (ipv6) '-6', | |
| sshTarget, | |
| remoteRunDebugCommand, | |
| ], | |
| forwardPortCommand: usePortForwarding | |
| ? <String>[ | |
| 'ssh', | |
| '-o', | |
| 'BatchMode=yes', | |
| '-o', | |
| 'ExitOnForwardFailure=yes', | |
| if (ipv6) '-6', | |
| '-L', | |
| '$formattedLoopbackIp:\${hostPort}:$formattedLoopbackIp:\${devicePort}', | |
| sshTarget, | |
| "echo 'Port forwarding success'; read", | |
| ] | |
| : null, | |
| forwardPortSuccessRegex: usePortForwarding ? RegExp('Port forwarding success') : null, | |
| screenshotCommand: screenshotCommand.isNotEmpty | |
| ? <String>['ssh', '-o', 'BatchMode=yes', if (ipv6) '-6', sshTarget, screenshotCommand] | |
| : null, | |
| ); | |
| final Platform platform = toolContext.platform; | |
| if (platform.isWindows) { | |
| config = config.copyWith( | |
| pingCommand: <String>['ping', if (ipv6) '-6', '-n', '1', '-w', '500', targetStr], | |
| explicitPingSuccessRegex: true, | |
| pingSuccessRegex: RegExp(r'[<=]\d+ms'), | |
| ); | |
| } else if (platform.isLinux || platform.isMacOS) { | |
| config = config.copyWith( | |
| pingCommand: <String>['ping', if (ipv6) '-6', '-c', '1', '-w', '1', targetStr], | |
| explicitPingSuccessRegex: true, | |
| ); | |
| } else { | |
| throw UnsupportedError('Unsupported operating system'); | |
| } | |
| final bool apply = await askApplyConfig( | |
| hasErrorsOrWarnings: shouldCheck && !(await _checkConfigWithLogging(config)), | |
| ); | |
| unawaited(keystrokesSubscription.cancel()); | |
| unawaited(nonClosingKeystrokes.close()); | |
| if (apply) { | |
| customDevicesConfig.add(config); | |
| printSuccessfullyAdded(); | |
| } | |
| return FlutterCommandResult.success(); | |
| } | |
| @override | |
| Future<FlutterCommandResult> runCommand() async { | |
| checkFeatureEnabled(); | |
| if (stringArg(_kJson) != null) { | |
| return runNonInteractively(); | |
| } | |
| if (boolArg(_kSsh)) { | |
| return runInteractivelySsh(); | |
| } | |
| throw UnsupportedError('Unknown run mode'); | |
| } | |
| } | |
| class CustomDevicesDeleteCommand extends CustomDevicesCommandBase { | |
| CustomDevicesDeleteCommand({required super.featureFlags, required super.toolContext}); | |
| @override | |
| String get description => ''' | |
| Delete a device from the config file. | |
| '''; | |
| @override | |
| String get name => 'delete'; | |
| @override | |
| Future<FlutterCommandResult> runCommand() async { | |
| checkFeatureEnabled(); | |
| final id = globalResults![FlutterGlobalOptions.kDeviceIdOption] as String?; | |
| final String configPath = customDevicesConfig.configPath; | |
| if (id == null || !customDevicesConfig.contains(id)) { | |
| throwToolExit('Couldn\'t find device with id "$id" in config at "$configPath"'); | |
| } | |
| backup(); | |
| customDevicesConfig.remove(id); | |
| logger.printStatus('Successfully removed device with id "$id" from config at "$configPath"'); | |
| return FlutterCommandResult.success(); | |
| } | |
| } |