
Fixes https://github.com/flutter/flutter/issues/158532 <details> <summary> Pre-launch checklist </summary> - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. </details> <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
811 lines
26 KiB
Dart
811 lines
26 KiB
Dart
// 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/os.dart';
|
|
import '../base/platform.dart';
|
|
import '../base/terminal.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 {
|
|
factory CustomDevicesCommand({
|
|
required CustomDevicesConfig customDevicesConfig,
|
|
required OperatingSystemUtils operatingSystemUtils,
|
|
required Terminal terminal,
|
|
required Platform platform,
|
|
required ProcessManager processManager,
|
|
required FileSystem fileSystem,
|
|
required Logger logger,
|
|
required FeatureFlags featureFlags,
|
|
}) {
|
|
return CustomDevicesCommand._common(
|
|
customDevicesConfig: customDevicesConfig,
|
|
operatingSystemUtils: operatingSystemUtils,
|
|
terminal: terminal,
|
|
platform: platform,
|
|
processManager: processManager,
|
|
fileSystem: fileSystem,
|
|
logger: logger,
|
|
featureFlags: featureFlags,
|
|
);
|
|
}
|
|
|
|
@visibleForTesting
|
|
factory CustomDevicesCommand.test({
|
|
required CustomDevicesConfig customDevicesConfig,
|
|
required OperatingSystemUtils operatingSystemUtils,
|
|
required Terminal terminal,
|
|
required Platform platform,
|
|
required ProcessManager processManager,
|
|
required FileSystem fileSystem,
|
|
required Logger logger,
|
|
required FeatureFlags featureFlags,
|
|
}) {
|
|
return CustomDevicesCommand._common(
|
|
customDevicesConfig: customDevicesConfig,
|
|
operatingSystemUtils: operatingSystemUtils,
|
|
terminal: terminal,
|
|
platform: platform,
|
|
processManager: processManager,
|
|
fileSystem: fileSystem,
|
|
logger: logger,
|
|
featureFlags: featureFlags,
|
|
);
|
|
}
|
|
|
|
CustomDevicesCommand._common({
|
|
required CustomDevicesConfig customDevicesConfig,
|
|
required OperatingSystemUtils operatingSystemUtils,
|
|
required Terminal terminal,
|
|
required Platform platform,
|
|
required ProcessManager processManager,
|
|
required FileSystem fileSystem,
|
|
required Logger logger,
|
|
required FeatureFlags featureFlags,
|
|
}) : _customDevicesConfig = customDevicesConfig,
|
|
_featureFlags = featureFlags {
|
|
addSubcommand(
|
|
CustomDevicesListCommand(
|
|
customDevicesConfig: customDevicesConfig,
|
|
featureFlags: featureFlags,
|
|
logger: logger,
|
|
),
|
|
);
|
|
addSubcommand(
|
|
CustomDevicesResetCommand(
|
|
customDevicesConfig: customDevicesConfig,
|
|
featureFlags: featureFlags,
|
|
fileSystem: fileSystem,
|
|
logger: logger,
|
|
),
|
|
);
|
|
addSubcommand(
|
|
CustomDevicesAddCommand(
|
|
customDevicesConfig: customDevicesConfig,
|
|
operatingSystemUtils: operatingSystemUtils,
|
|
terminal: terminal,
|
|
platform: platform,
|
|
featureFlags: featureFlags,
|
|
processManager: processManager,
|
|
fileSystem: fileSystem,
|
|
logger: logger,
|
|
),
|
|
);
|
|
addSubcommand(
|
|
CustomDevicesDeleteCommand(
|
|
customDevicesConfig: customDevicesConfig,
|
|
featureFlags: featureFlags,
|
|
fileSystem: fileSystem,
|
|
logger: logger,
|
|
),
|
|
);
|
|
}
|
|
|
|
final CustomDevicesConfig _customDevicesConfig;
|
|
final FeatureFlags _featureFlags;
|
|
|
|
@override
|
|
String get description {
|
|
String configFileLine;
|
|
if (_featureFlags.areCustomDevicesEnabled) {
|
|
configFileLine =
|
|
'\nMakes changes to the config file at "${_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.customDevicesConfig,
|
|
required this.featureFlags,
|
|
required this.fileSystem,
|
|
required this.logger,
|
|
});
|
|
|
|
@protected
|
|
final CustomDevicesConfig customDevicesConfig;
|
|
@protected
|
|
final FeatureFlags featureFlags;
|
|
@protected
|
|
final FileSystem? fileSystem;
|
|
@protected
|
|
final Logger 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.customDevicesConfig,
|
|
required super.featureFlags,
|
|
required super.logger,
|
|
}) : super(fileSystem: null);
|
|
|
|
@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.');
|
|
}
|
|
|
|
if (devices.isEmpty) {
|
|
logger.printStatus('No custom devices found in "${customDevicesConfig.configPath}"');
|
|
} else {
|
|
logger.printStatus('List of custom devices in "${customDevicesConfig.configPath}":');
|
|
for (final CustomDeviceConfig 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.customDevicesConfig,
|
|
required super.featureFlags,
|
|
required FileSystem super.fileSystem,
|
|
required super.logger,
|
|
});
|
|
|
|
@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.customDevicesConfig,
|
|
required OperatingSystemUtils operatingSystemUtils,
|
|
required Terminal terminal,
|
|
required Platform platform,
|
|
required super.featureFlags,
|
|
required ProcessManager processManager,
|
|
required FileSystem super.fileSystem,
|
|
required super.logger,
|
|
}) : _operatingSystemUtils = operatingSystemUtils,
|
|
_terminal = terminal,
|
|
_platform = platform,
|
|
_processManager = processManager {
|
|
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 String _kJson = 'json';
|
|
static const List<String> _kJsonAliases = <String>['js'];
|
|
static const String _kCheck = 'check';
|
|
static const String _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 RegExp _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])$',
|
|
);
|
|
|
|
final OperatingSystemUtils _operatingSystemUtils;
|
|
final Terminal _terminal;
|
|
final Platform _platform;
|
|
final ProcessManager _processManager;
|
|
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(final CustomDeviceConfig config) async {
|
|
final CustomDevice device = CustomDevice(
|
|
config: config,
|
|
logger: logger,
|
|
processManager: _processManager,
|
|
);
|
|
|
|
bool 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 CustomDevicePortForwarder portForwarder = CustomDevicePortForwarder(
|
|
deviceName: device.displayName,
|
|
forwardPortCommand: config.forwardPortCommand!,
|
|
forwardPortSuccessRegex: config.forwardPortSuccessRegex!,
|
|
processManager: _processManager,
|
|
logger: logger,
|
|
);
|
|
|
|
try {
|
|
// find a random port we can forward
|
|
final int port = await _operatingSystemUtils.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 String 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 StreamController<String> nonClosingKeystrokes = StreamController<String>();
|
|
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 bool 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;
|
|
|
|
CustomDeviceConfig 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,
|
|
);
|
|
|
|
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.customDevicesConfig,
|
|
required super.featureFlags,
|
|
required FileSystem super.fileSystem,
|
|
required super.logger,
|
|
});
|
|
|
|
@override
|
|
String get description => '''
|
|
Delete a device from the config file.
|
|
''';
|
|
|
|
@override
|
|
String get name => 'delete';
|
|
|
|
@override
|
|
Future<FlutterCommandResult> runCommand() async {
|
|
checkFeatureEnabled();
|
|
|
|
final String? id = globalResults![FlutterGlobalOptions.kDeviceIdOption] as String?;
|
|
if (id == null || !customDevicesConfig.contains(id)) {
|
|
throwToolExit(
|
|
'Couldn\'t find device with id "$id" in config at "${customDevicesConfig.configPath}"',
|
|
);
|
|
}
|
|
|
|
backup();
|
|
customDevicesConfig.remove(id);
|
|
logger.printStatus(
|
|
'Successfully removed device with id "$id" from config at "${customDevicesConfig.configPath}"',
|
|
);
|
|
return FlutterCommandResult.success();
|
|
}
|
|
}
|