Unverified Commit a1e9f205 authored by Hannes Winkler's avatar Hannes Winkler Committed by GitHub

[custom-devices] general improvements, add custom-devices subcommand, better...

[custom-devices] general improvements, add custom-devices subcommand, better error handling (#82043)
parent ba143ae2
...@@ -24,6 +24,7 @@ import 'src/commands/channel.dart'; ...@@ -24,6 +24,7 @@ import 'src/commands/channel.dart';
import 'src/commands/clean.dart'; import 'src/commands/clean.dart';
import 'src/commands/config.dart'; import 'src/commands/config.dart';
import 'src/commands/create.dart'; import 'src/commands/create.dart';
import 'src/commands/custom_devices.dart';
import 'src/commands/daemon.dart'; import 'src/commands/daemon.dart';
import 'src/commands/devices.dart'; import 'src/commands/devices.dart';
import 'src/commands/doctor.dart'; import 'src/commands/doctor.dart';
...@@ -149,6 +150,16 @@ List<FlutterCommand> generateCommands({ ...@@ -149,6 +150,16 @@ List<FlutterCommand> generateCommands({
ChannelCommand(verboseHelp: verboseHelp), ChannelCommand(verboseHelp: verboseHelp),
CleanCommand(verbose: verbose), CleanCommand(verbose: verbose),
ConfigCommand(verboseHelp: verboseHelp), ConfigCommand(verboseHelp: verboseHelp),
CustomDevicesCommand(
customDevicesConfig: globals.customDevicesConfig,
operatingSystemUtils: globals.os,
terminal: globals.terminal,
platform: globals.platform,
featureFlags: featureFlags,
processManager: globals.processManager,
fileSystem: globals.fs,
logger: globals.logger
),
CreateCommand(verboseHelp: verboseHelp), CreateCommand(verboseHelp: verboseHelp),
DaemonCommand(hidden: !verboseHelp), DaemonCommand(hidden: !verboseHelp),
DevicesCommand(verboseHelp: verboseHelp), DevicesCommand(verboseHelp: verboseHelp),
......
...@@ -23,17 +23,59 @@ class Config { ...@@ -23,17 +23,59 @@ class Config {
/// `.flutter_$name` already exists there. On other platforms the /// `.flutter_$name` already exists there. On other platforms the
/// configuration file will always be a file named `.flutter_$name` in the /// configuration file will always be a file named `.flutter_$name` in the
/// home directory. /// home directory.
///
/// Uses some good default behaviours:
/// - deletes the file if it's not valid JSON
/// - reports an empty config in that case
/// - logs and catches any exceptions
factory Config( factory Config(
String name, {
required FileSystem fileSystem,
required Logger logger,
required Platform platform
}) {
return Config._common(
name,
fileSystem: fileSystem,
logger: logger,
platform: platform,
managed: false
);
}
/// Similiar to the default config constructor, but with some different
/// behaviours:
/// - will not delete the config if it's not valid JSON
/// - will log but also rethrow any exceptions while loading the JSON, so
/// you can actually detect whether something went wrong
///
/// Useful if you want some more control.
factory Config.managed(
String name, {
required FileSystem fileSystem,
required Logger logger,
required Platform platform
}) {
return Config._common(
name,
fileSystem: fileSystem,
logger: logger,
platform: platform,
managed: true
);
}
factory Config._common(
String name, { String name, {
required FileSystem fileSystem, required FileSystem fileSystem,
required Logger logger, required Logger logger,
required Platform platform, required Platform platform,
bool deleteFileOnFormatException = true bool managed = false
}) { }) {
final String filePath = _configPath(platform, fileSystem, name); final String filePath = _configPath(platform, fileSystem, name);
final File file = fileSystem.file(filePath); final File file = fileSystem.file(filePath);
file.parent.createSync(recursive: true); file.parent.createSync(recursive: true);
return Config.createForTesting(file, logger, deleteFileOnFormatException: deleteFileOnFormatException); return Config.createForTesting(file, logger, managed: managed);
} }
/// Constructs a new [Config] object from a file called [name] in /// Constructs a new [Config] object from a file called [name] in
...@@ -44,19 +86,19 @@ class Config { ...@@ -44,19 +86,19 @@ class Config {
String name = 'test', String name = 'test',
Directory? directory, Directory? directory,
Logger? logger, Logger? logger,
bool deleteFileOnFormatException = true bool managed = false
}) { }) {
directory ??= MemoryFileSystem.test().directory('/'); directory ??= MemoryFileSystem.test().directory('/');
return Config.createForTesting( return Config.createForTesting(
directory.childFile('.${kConfigDir}_$name'), directory.childFile('.${kConfigDir}_$name'),
logger ?? BufferLogger.test(), logger ?? BufferLogger.test(),
deleteFileOnFormatException: deleteFileOnFormatException managed: managed
); );
} }
/// Test only access to the Config constructor. /// Test only access to the Config constructor.
@visibleForTesting @visibleForTesting
Config.createForTesting(File file, Logger logger, {bool deleteFileOnFormatException = true}) : _file = file, _logger = logger { Config.createForTesting(File file, Logger logger, {bool managed = false}) : _file = file, _logger = logger {
if (!_file.existsSync()) { if (!_file.existsSync()) {
return; return;
} }
...@@ -68,20 +110,26 @@ class Config { ...@@ -68,20 +110,26 @@ class Config {
_logger _logger
..printError('Failed to decode preferences in ${_file.path}.') ..printError('Failed to decode preferences in ${_file.path}.')
..printError( ..printError(
'You may need to reapply any previously saved configuration ' 'You may need to reapply any previously saved configuration '
'with the "flutter config" command.', 'with the "flutter config" command.',
); );
if (deleteFileOnFormatException) { if (managed) {
rethrow;
} else {
_file.deleteSync(); _file.deleteSync();
} }
} on Exception catch (err) { } on Exception catch (err) {
_logger _logger
..printError('Could not read preferences in ${file.path}.\n$err') ..printError('Could not read preferences in ${file.path}.\n$err')
..printError( ..printError(
'You may need to resolve the error above and reapply any previously ' 'You may need to resolve the error above and reapply any previously '
'saved configuration with the "flutter config" command.', 'saved configuration with the "flutter config" command.',
); );
if (managed) {
rethrow;
}
} }
} }
......
...@@ -115,6 +115,9 @@ abstract class Terminal { ...@@ -115,6 +115,9 @@ abstract class Terminal {
/// Return keystrokes from the console. /// Return keystrokes from the console.
/// ///
/// This is a single-subscription stream. This stream may be closed before
/// the application exits.
///
/// Useful when the console is in [singleCharMode]. /// Useful when the console is in [singleCharMode].
Stream<String> get keystrokes; Stream<String> get keystrokes;
......
// 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.
// @dart = 2.8
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';
/// just the function signature of the [print] function.
/// The Object arg may be null.
typedef PrintFn = void Function(Object);
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,
PrintFn usagePrintFn = print
}) {
return CustomDevicesCommand._common(
customDevicesConfig: customDevicesConfig,
operatingSystemUtils: operatingSystemUtils,
terminal: terminal,
platform: platform,
processManager: processManager,
fileSystem: fileSystem,
logger: logger,
featureFlags: featureFlags,
usagePrintFn: usagePrintFn
);
}
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,
PrintFn usagePrintFn = print,
}) : assert(customDevicesConfig != null),
assert(operatingSystemUtils != null),
assert(terminal != null),
assert(platform != null),
assert(processManager != null),
assert(fileSystem != null),
assert(logger != null),
assert(featureFlags != null),
assert(usagePrintFn != null),
_customDevicesConfig = customDevicesConfig,
_featureFlags = featureFlags,
_usagePrintFn = usagePrintFn
{
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;
final void Function(Object) _usagePrintFn;
@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
Future<FlutterCommandResult> runCommand() async => null;
@override
void printUsage() {
_usagePrintFn(usage);
}
}
/// 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 CustomDevicesConfig customDevicesConfig,
@required FeatureFlags featureFlags,
@required Logger logger,
}) : super(
customDevicesConfig: customDevicesConfig,
featureFlags: featureFlags,
fileSystem: null,
logger: logger
);
@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();
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 CustomDevicesConfig customDevicesConfig,
@required FeatureFlags featureFlags,
@required FileSystem fileSystem,
@required Logger logger,
}) : super(
customDevicesConfig: customDevicesConfig,
featureFlags: featureFlags,
fileSystem: fileSystem,
logger: 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 resetted the custom devices config file and created a '
'backup at "$configBackupPath".'
: 'Successfully resetted the custom devices config file.'
);
return FlutterCommandResult.success();
}
}
class CustomDevicesAddCommand extends CustomDevicesCommandBase {
CustomDevicesAddCommand({
@required CustomDevicesConfig customDevicesConfig,
@required OperatingSystemUtils operatingSystemUtils,
@required Terminal terminal,
@required Platform platform,
@required FeatureFlags featureFlags,
@required ProcessManager processManager,
@required FileSystem fileSystem,
@required Logger logger,
}) : _operatingSystemUtils = operatingSystemUtils,
_terminal = terminal,
_platform = platform,
_processManager = processManager,
super(
customDevicesConfig: customDevicesConfig,
featureFlags: featureFlags,
fileSystem: fileSystem,
logger: logger
)
{
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", seperated 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;
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.name,
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;
}
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) ?? false;
dynamic json;
try {
json = jsonDecode(jsonStr);
} on FormatException catch (e) {
throwToolExit('Could not decode json: $e');
}
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 == true ? '[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) ?? true;
// 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?',
defaultsTo: true,
);
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 "--observatory-host=<ip>" engine option, you might prefer '
'not using port forwarding.',
defaultsTo: true,
);
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>[],
pingSuccessRegex: null,
postBuildCommand: null,
// 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
]
: null,
forwardPortSuccessRegex: usePortForwarding
? RegExp('Linux')
: 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,
pingSuccessRegex: null
);
} else {
throw FallThroughError();
}
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) == true) {
return runInteractivelySsh();
}
throw FallThroughError();
}
}
class CustomDevicesDeleteCommand extends CustomDevicesCommandBase {
CustomDevicesDeleteCommand({
@required CustomDevicesConfig customDevicesConfig,
@required FeatureFlags featureFlags,
@required FileSystem fileSystem,
@required Logger logger,
}) : super(
customDevicesConfig: customDevicesConfig,
featureFlags: featureFlags,
fileSystem: fileSystem,
logger: 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['device-id'] as String;
if (!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();
}
}
...@@ -172,6 +172,11 @@ Future<T> runInContext<T>( ...@@ -172,6 +172,11 @@ Future<T> runInContext<T>(
logger: globals.logger, logger: globals.logger,
platform: globals.platform, platform: globals.platform,
), ),
CustomDevicesConfig: () => CustomDevicesConfig(
fileSystem: globals.fs,
logger: globals.logger,
platform: globals.platform
),
CrashReporter: () => CrashReporter( CrashReporter: () => CrashReporter(
fileSystem: globals.fs, fileSystem: globals.fs,
logger: globals.logger, logger: globals.logger,
...@@ -201,11 +206,7 @@ Future<T> runInContext<T>( ...@@ -201,11 +206,7 @@ Future<T> runInContext<T>(
), ),
operatingSystemUtils: globals.os, operatingSystemUtils: globals.os,
terminal: globals.terminal, terminal: globals.terminal,
customDevicesConfig: CustomDevicesConfig( customDevicesConfig: globals.customDevicesConfig,
fileSystem: globals.fs,
logger: globals.logger,
platform: globals.platform
),
uwptool: UwpTool( uwptool: UwpTool(
artifacts: globals.artifacts, artifacts: globals.artifacts,
logger: globals.logger, logger: globals.logger,
......
...@@ -135,7 +135,7 @@ class CustomDevicePortForwarder extends DevicePortForwarder { ...@@ -135,7 +135,7 @@ class CustomDevicePortForwarder extends DevicePortForwarder {
return Future.wait(List<ForwardedPort>.of(_forwardedPorts).map(unforward)); return Future.wait(List<ForwardedPort>.of(_forwardedPorts).map(unforward));
} }
Future<ForwardedPort> _tryForward(int devicePort, int hostPort) async { Future<ForwardedPort> tryForward(int devicePort, int hostPort) async {
final List<String> interpolated = interpolateCommand( final List<String> interpolated = interpolateCommand(
_forwardPortCommand, _forwardPortCommand,
<String, String>{ <String, String>{
...@@ -192,7 +192,7 @@ class CustomDevicePortForwarder extends DevicePortForwarder { ...@@ -192,7 +192,7 @@ class CustomDevicePortForwarder extends DevicePortForwarder {
actualHostPort += 1; actualHostPort += 1;
} }
final ForwardedPort port = await _tryForward(devicePort, actualHostPort); final ForwardedPort port = await tryForward(devicePort, actualHostPort);
if (port != null) { if (port != null) {
_forwardedPorts.add(port); _forwardedPorts.add(port);
...@@ -238,6 +238,10 @@ class CustomDeviceAppSession { ...@@ -238,6 +238,10 @@ class CustomDeviceAppSession {
_device = device, _device = device,
_logger = logger, _logger = logger,
_processManager = processManager, _processManager = processManager,
_processUtils = ProcessUtils(
processManager: processManager,
logger: logger
),
logReader = CustomDeviceLogReader(name); logReader = CustomDeviceLogReader(name);
final String name; final String name;
...@@ -245,29 +249,136 @@ class CustomDeviceAppSession { ...@@ -245,29 +249,136 @@ class CustomDeviceAppSession {
final ApplicationPackage _appPackage; final ApplicationPackage _appPackage;
final Logger _logger; final Logger _logger;
final ProcessManager _processManager; final ProcessManager _processManager;
final ProcessUtils _processUtils;
final CustomDeviceLogReader logReader; final CustomDeviceLogReader logReader;
Process _process; Process _process;
int _forwardedHostPort; 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', 'enable-background-compilation=true']`
List<String> _getEngineOptions(DebuggingOptions debuggingOptions, bool traceStartup, String route) {
final List<String> options = <String>[];
void addFlag(String value) {
options.add(value);
}
addFlag('enable-dart-profiling=true');
addFlag('enable-background-compilation=true');
if (traceStartup) {
addFlag('trace-startup=true');
}
if (route != null) {
addFlag('route=$route');
}
if (debuggingOptions != null) {
if (debuggingOptions.enableSoftwareRendering) {
addFlag('enable-software-rendering=true');
}
if (debuggingOptions.skiaDeterministicRendering) {
addFlag('skia-deterministic-rendering=true');
}
if (debuggingOptions.traceSkia) {
addFlag('trace-skia=true');
}
if (debuggingOptions.traceAllowlist != null) {
addFlag('trace-allowlist=${debuggingOptions.traceAllowlist}');
}
if (debuggingOptions.traceSystrace) {
addFlag('trace-systrace=true');
}
if (debuggingOptions.endlessTraceBuffer) {
addFlag('endless-trace-buffer=true');
}
if (debuggingOptions.dumpSkpOnShaderCompilation) {
addFlag('dump-skp-on-shader-compilation=true');
}
if (debuggingOptions.cacheSkSL) {
addFlag('cache-sksl=true');
}
if (debuggingOptions.purgePersistentCache) {
addFlag('purge-persistent-cache=true');
}
// Options only supported when there is a VM Service connection between the
// tool and the device, usually in debug or profile mode.
if (debuggingOptions.debuggingEnabled) {
if (debuggingOptions.deviceVmServicePort != null) {
addFlag('observatory-port=${debuggingOptions.deviceVmServicePort}');
}
if (debuggingOptions.buildInfo.isDebug) {
addFlag('enable-checked-mode=true');
addFlag('verify-entry-points=true');
}
if (debuggingOptions.startPaused) {
addFlag('start-paused=true');
}
if (debuggingOptions.disableServiceAuthCodes) {
addFlag('disable-service-auth-codes=true');
}
final String dartVmFlags = computeDartVmFlags(debuggingOptions);
if (dartVmFlags.isNotEmpty) {
addFlag('dart-flags=$dartVmFlags');
}
if (debuggingOptions.useTestFonts) {
addFlag('use-test-fonts=true');
}
if (debuggingOptions.verboseSystemLogs) {
addFlag('verbose-logging=true');
}
}
}
return options;
}
/// 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 --enable-background-compilation=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({ Future<LaunchResult> start({
String mainPath, String mainPath,
String route, String route,
DebuggingOptions debuggingOptions, DebuggingOptions debuggingOptions,
Map<String, dynamic> platformArgs, Map<String, dynamic> platformArgs = const <String, dynamic>{},
bool prebuiltApplication = false, bool prebuiltApplication = false,
bool ipv6 = false, bool ipv6 = false,
String userIdentifier String userIdentifier
}) async { }) async {
platformArgs ??= <String, dynamic>{};
final bool traceStartup = platformArgs['trace-startup'] as bool ?? false;
final List<String> interpolated = interpolateCommand( final List<String> interpolated = interpolateCommand(
_device._config.runDebugCommand, _device._config.runDebugCommand,
<String, String>{ <String, String>{
'remotePath': '/tmp/', 'remotePath': '/tmp/',
'appName': _appPackage.name 'appName': _appPackage.name,
'engineOptions': _getEngineOptionsForCmdline(debuggingOptions, traceStartup, route)
} }
); );
final Process process = await _processManager.start(interpolated); final Process process = await _processUtils.start(interpolated);
assert(_process == null); assert(_process == null);
_process = process; _process = process;
...@@ -307,6 +418,9 @@ class CustomDeviceAppSession { ...@@ -307,6 +418,9 @@ class CustomDeviceAppSession {
} }
} }
/// Stop the app on the device.
/// Returns false if the app is not yet running. Also unforwards any
/// forwarded ports.
Future<bool> stop() async { Future<bool> stop() async {
if (_process == null) { if (_process == null) {
return false; return false;
...@@ -403,7 +517,7 @@ class CustomDevice extends Device { ...@@ -403,7 +517,7 @@ class CustomDevice extends Device {
/// it will be killed with a SIGTERM, false will be returned and the timeout /// 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] /// will be reported in the log using [_logger.printError]. If [timeout]
/// is null, it's treated as if it's an infinite timeout. /// is null, it's treated as if it's an infinite timeout.
Future<bool> _tryPing({ Future<bool> tryPing({
Duration timeout, Duration timeout,
Map<String, String> replacementValues = const <String, String>{} Map<String, String> replacementValues = const <String, String>{}
}) async { }) async {
...@@ -412,23 +526,21 @@ class CustomDevice extends Device { ...@@ -412,23 +526,21 @@ class CustomDevice extends Device {
replacementValues replacementValues
); );
try { final RunResult result = await _processUtils.run(
final RunResult result = await _processUtils.run( interpolated,
interpolated, timeout: timeout
throwOnError: true, );
timeout: timeout
);
// If the user doesn't configure a ping success regex, any ping with exitCode zero if (result.exitCode != 0) {
// is good enough. Otherwise we check if either stdout or stderr have a match of
// the pingSuccessRegex.
return _config.pingSuccessRegex == null
|| _config.pingSuccessRegex.hasMatch(result.stdout)
|| _config.pingSuccessRegex.hasMatch(result.stderr);
} on ProcessException catch (e) {
_logger.printError('Error executing ping command for custom device $id: $e');
return false; 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.
return _config.pingSuccessRegex == null
|| _config.pingSuccessRegex.hasMatch(result.stdout)
|| _config.pingSuccessRegex.hasMatch(result.stderr);
} }
/// Tries to execute the configs postBuild command using [appName] for the /// Tries to execute the configs postBuild command using [appName] for the
...@@ -481,7 +593,7 @@ class CustomDevice extends Device { ...@@ -481,7 +593,7 @@ class CustomDevice extends Device {
/// will be killed with a SIGTERM, false will be returned and the timeout /// will be killed with a SIGTERM, false will be returned and the timeout
/// will be reported in the log using [_logger.printError]. If [timeout] /// will be reported in the log using [_logger.printError]. If [timeout]
/// is null, it's treated as if it's an infinite timeout. /// is null, it's treated as if it's an infinite timeout.
Future<bool> _tryUninstall({ Future<bool> tryUninstall({
@required String appName, @required String appName,
Duration timeout, Duration timeout,
Map<String, String> additionalReplacementValues = const <String, String>{} Map<String, String> additionalReplacementValues = const <String, String>{}
...@@ -515,7 +627,7 @@ class CustomDevice extends Device { ...@@ -515,7 +627,7 @@ class CustomDevice extends Device {
/// ///
/// [appName] is the name of the app to be installed. Substituted for any occurrence /// [appName] is the name of the app to be installed. Substituted for any occurrence
/// of `${appName}` in the custom device configs `install` command. /// of `${appName}` in the custom device configs `install` command.
Future<bool> _tryInstall({ Future<bool> tryInstall({
@required String localPath, @required String localPath,
@required String appName, @required String appName,
Duration timeout, Duration timeout,
...@@ -571,11 +683,11 @@ class CustomDevice extends Device { ...@@ -571,11 +683,11 @@ class CustomDevice extends Device {
@override @override
Future<bool> installApp(covariant ApplicationPackage app, {String userIdentifier}) async { Future<bool> installApp(covariant ApplicationPackage app, {String userIdentifier}) async {
if (!await _tryUninstall(appName: app.name)) { if (!await tryUninstall(appName: app.name)) {
return false; return false;
} }
final bool result = await _tryInstall( final bool result = await tryInstall(
localPath: getAssetBuildDirectory(), localPath: getAssetBuildDirectory(),
appName: app.name appName: app.name
); );
...@@ -692,12 +804,11 @@ class CustomDevice extends Device { ...@@ -692,12 +804,11 @@ class CustomDevice extends Device {
} }
@override @override
// TODO(ardera): Allow configuring or auto-detecting the target platform, https://github.com/flutter/flutter/issues/78151 Future<TargetPlatform> get targetPlatform async => _config.platform ?? TargetPlatform.linux_arm64;
Future<TargetPlatform> get targetPlatform async => TargetPlatform.linux_arm64;
@override @override
Future<bool> uninstallApp(covariant ApplicationPackage app, {String userIdentifier}) { Future<bool> uninstallApp(covariant ApplicationPackage app, {String userIdentifier}) {
return _tryUninstall(appName: app.name); return tryUninstall(appName: app.name);
} }
} }
...@@ -732,9 +843,9 @@ class CustomDevices extends PollingDeviceDiscovery { ...@@ -732,9 +843,9 @@ class CustomDevices extends PollingDeviceDiscovery {
CustomDevicesConfig get _customDevicesConfig => _config; CustomDevicesConfig get _customDevicesConfig => _config;
List<CustomDevice> get enabledCustomDevices { List<CustomDevice> get _enabledCustomDevices {
return _customDevicesConfig.devices return _customDevicesConfig.tryGetDevices()
.where((CustomDeviceConfig element) => !element.disabled) .where((CustomDeviceConfig element) => element.enabled)
.map( .map(
(CustomDeviceConfig config) => CustomDevice( (CustomDeviceConfig config) => CustomDevice(
config: config, config: config,
...@@ -750,12 +861,12 @@ class CustomDevices extends PollingDeviceDiscovery { ...@@ -750,12 +861,12 @@ class CustomDevices extends PollingDeviceDiscovery {
return const <Device>[]; return const <Device>[];
} }
final List<CustomDevice> devices = enabledCustomDevices; final List<CustomDevice> devices = _enabledCustomDevices;
// maps any custom device to whether its reachable or not. // maps any custom device to whether its reachable or not.
final Map<CustomDevice, bool> pingedDevices = Map<CustomDevice, bool>.fromIterables( final Map<CustomDevice, bool> pingedDevices = Map<CustomDevice, bool>.fromIterables(
devices, devices,
await Future.wait(devices.map((CustomDevice e) => e._tryPing(timeout: timeout))) await Future.wait(devices.map((CustomDevice e) => e.tryPing(timeout: timeout)))
); );
// remove all the devices we couldn't reach. // remove all the devices we couldn't reach.
......
...@@ -4,6 +4,76 @@ ...@@ -4,6 +4,76 @@
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import '../base/platform.dart';
import '../build_info.dart';
/// Quiver has this, but unfortunately we can't depend on it bc flutter_tools
/// uses non-nullsafe quiver by default (because of dwds).
bool _listsEqual(List<dynamic>? a, List<dynamic>? b) {
if (a == b) {
return true;
}
if (a == null || b == null) {
return false;
}
if (a.length != b.length) {
return false;
}
return a.asMap().entries.every((MapEntry<int, dynamic> e) => e.value == b[e.key]);
}
/// The normal [RegExp.==] operator is inherited from [Object], so only
/// returns true when the regexes are the same instance.
///
/// This function instead _should_ return true when the regexes are
/// functionally the same, i.e. when they have the same matches & captures for
/// any given input. At least that's the goal, in reality this has lots of false
/// negatives (for example when the flags differ). Still better than [RegExp.==].
bool _regexesEqual(RegExp? a, RegExp? b) {
if (a == b) {
return true;
}
if (a == null || b == null) {
return false;
}
return a.pattern == b.pattern
&& a.isMultiLine == b.isMultiLine
&& a.isCaseSensitive == b.isCaseSensitive
&& a.isUnicode == b.isUnicode
&& a.isDotAll == b.isDotAll;
}
/// Something went wrong while trying to load the custom devices config from the
/// JSON representation. Maybe some value is missing, maybe something has the
/// wrong type, etc.
@immutable
class CustomDeviceRevivalException implements Exception {
const CustomDeviceRevivalException(this.message);
const CustomDeviceRevivalException.fromDescriptions(
String fieldDescription,
String expectedValueDescription
) : message = 'Expected $fieldDescription to be $expectedValueDescription.';
final String message;
@override
String toString() {
return message;
}
@override
bool operator ==(Object other) {
return (other is CustomDeviceRevivalException) &&
(other.message == message);
}
@override
int get hashCode => message.hashCode;
}
/// A single configured custom device. /// A single configured custom device.
/// ///
/// In the custom devices config file on disk, there may be multiple custom /// In the custom devices config file on disk, there may be multiple custom
...@@ -14,7 +84,8 @@ class CustomDeviceConfig { ...@@ -14,7 +84,8 @@ class CustomDeviceConfig {
required this.id, required this.id,
required this.label, required this.label,
required this.sdkNameAndVersion, required this.sdkNameAndVersion,
required this.disabled, this.platform,
required this.enabled,
required this.pingCommand, required this.pingCommand,
this.pingSuccessRegex, this.pingSuccessRegex,
required this.postBuildCommand, required this.postBuildCommand,
...@@ -24,32 +95,123 @@ class CustomDeviceConfig { ...@@ -24,32 +95,123 @@ class CustomDeviceConfig {
this.forwardPortCommand, this.forwardPortCommand,
this.forwardPortSuccessRegex, this.forwardPortSuccessRegex,
this.screenshotCommand this.screenshotCommand
}) : assert(forwardPortCommand == null || forwardPortSuccessRegex != null); }) : assert(forwardPortCommand == null || forwardPortSuccessRegex != null),
assert(
platform == null
|| platform == TargetPlatform.linux_x64
|| platform == TargetPlatform.linux_arm64
);
/// Create a CustomDeviceConfig from some JSON value.
/// If anything fails internally (some value doesn't have the right type,
/// some value is missing, etc) a [CustomDeviceRevivalException] with the description
/// of the error is thrown. (No exceptions/errors other than JsonRevivalException
/// should ever be thrown by this factory.)
factory CustomDeviceConfig.fromJson(dynamic json) { factory CustomDeviceConfig.fromJson(dynamic json) {
final Map<String, Object> typedMap = (json as Map<dynamic, dynamic>).cast<String, Object>(); final Map<String, dynamic> typedMap = _castJsonObject(
json,
'device configuration',
'a JSON object'
);
final List<String>? forwardPortCommand = _castStringListOrNull(
typedMap[_kForwardPortCommand],
_kForwardPortCommand,
'null or array of strings with at least one element',
minLength: 1
);
final RegExp? forwardPortSuccessRegex = _convertToRegexOrNull(
typedMap[_kForwardPortSuccessRegex],
_kForwardPortSuccessRegex,
'null or string-ified regex'
);
final String? archString = _castStringOrNull(
typedMap[_kPlatform],
_kPlatform,
'null or one of linux-arm64, linux-x64'
);
late TargetPlatform? platform;
try {
platform = archString == null
? null
: getTargetPlatformForName(archString);
} on FallThroughError {
throw const CustomDeviceRevivalException.fromDescriptions(
_kPlatform,
'null or one of linux-arm64, linux-x64'
);
}
if (platform != null
&& platform != TargetPlatform.linux_arm64
&& platform != TargetPlatform.linux_x64
) {
throw const CustomDeviceRevivalException.fromDescriptions(
_kPlatform,
'null or one of linux-arm64, linux-x64'
);
}
if (forwardPortCommand != null && forwardPortSuccessRegex == null) {
throw const CustomDeviceRevivalException('When forwardPort is given, forwardPortSuccessRegex must be specified too.');
}
return CustomDeviceConfig( return CustomDeviceConfig(
id: typedMap[_kId]! as String, id: _castString(typedMap[_kId], _kId, 'a string'),
label: typedMap[_kLabel]! as String, label: _castString(typedMap[_kLabel], _kLabel, 'a string'),
sdkNameAndVersion: typedMap[_kSdkNameAndVersion]! as String, sdkNameAndVersion: _castString(typedMap[_kSdkNameAndVersion], _kSdkNameAndVersion, 'a string'),
disabled: typedMap[_kDisabled]! as bool, platform: platform,
pingCommand: _castStringList(typedMap[_kPingCommand]!), enabled: _castBool(typedMap[_kEnabled], _kEnabled, 'a boolean'),
pingSuccessRegex: _convertToRegexOrNull(typedMap[_kPingSuccessRegex]), pingCommand: _castStringList(
postBuildCommand: _castStringListOrNull(typedMap[_kPostBuildCommand]), typedMap[_kPingCommand],
installCommand: _castStringList(typedMap[_kInstallCommand]!), _kPingCommand,
uninstallCommand: _castStringList(typedMap[_kUninstallCommand]!), 'array of strings with at least one element',
runDebugCommand: _castStringList(typedMap[_kRunDebugCommand]!), minLength: 1
forwardPortCommand: _castStringListOrNull(typedMap[_kForwardPortCommand]), ),
forwardPortSuccessRegex: _convertToRegexOrNull(typedMap[_kForwardPortSuccessRegex]), pingSuccessRegex: _convertToRegexOrNull(typedMap[_kPingSuccessRegex], _kPingSuccessRegex, 'null or string-ified regex'),
screenshotCommand: _castStringListOrNull(typedMap[_kScreenshotCommand]) postBuildCommand: _castStringListOrNull(
typedMap[_kPostBuildCommand],
_kPostBuildCommand,
'null or array of strings with at least one element',
minLength: 1,
),
installCommand: _castStringList(
typedMap[_kInstallCommand],
_kInstallCommand,
'array of strings with at least one element',
minLength: 1
),
uninstallCommand: _castStringList(
typedMap[_kUninstallCommand],
_kUninstallCommand,
'array of strings with at least one element',
minLength: 1
),
runDebugCommand: _castStringList(
typedMap[_kRunDebugCommand],
_kRunDebugCommand,
'array of strings with at least one element',
minLength: 1
),
forwardPortCommand: forwardPortCommand,
forwardPortSuccessRegex: forwardPortSuccessRegex,
screenshotCommand: _castStringListOrNull(
typedMap[_kScreenshotCommand],
_kScreenshotCommand,
'array of strings with at least one element',
minLength: 1
)
); );
} }
static const String _kId = 'id'; static const String _kId = 'id';
static const String _kLabel = 'label'; static const String _kLabel = 'label';
static const String _kSdkNameAndVersion = 'sdkNameAndVersion'; static const String _kSdkNameAndVersion = 'sdkNameAndVersion';
static const String _kDisabled = 'disabled'; static const String _kPlatform = 'platform';
static const String _kEnabled = 'enabled';
static const String _kPingCommand = 'ping'; static const String _kPingCommand = 'ping';
static const String _kPingSuccessRegex = 'pingSuccessRegex'; static const String _kPingSuccessRegex = 'pingSuccessRegex';
static const String _kPostBuildCommand = 'postBuild'; static const String _kPostBuildCommand = 'postBuild';
...@@ -61,26 +223,91 @@ class CustomDeviceConfig { ...@@ -61,26 +223,91 @@ class CustomDeviceConfig {
static const String _kScreenshotCommand = 'screenshot'; static const String _kScreenshotCommand = 'screenshot';
/// An example device config used for creating the default config file. /// An example device config used for creating the default config file.
static final CustomDeviceConfig example = CustomDeviceConfig( /// Uses windows-specific ping and pingSuccessRegex. For the linux and macOs
id: 'test1', /// example config, see [exampleUnix].
label: 'Test Device', static final CustomDeviceConfig exampleWindows = CustomDeviceConfig(
sdkNameAndVersion: 'Test Device 4 Model B+', id: 'pi',
disabled: true, label: 'Raspberry Pi',
pingCommand: const <String>['ping', '-w', '500', '-n', '1', 'raspberrypi'], sdkNameAndVersion: 'Raspberry Pi 4 Model B+',
pingSuccessRegex: RegExp('ms TTL='), platform: TargetPlatform.linux_arm64,
enabled: false,
pingCommand: const <String>[
'ping',
'-w', '500',
'-n', '1',
'raspberrypi',
],
pingSuccessRegex: RegExp(r'[<=]\d+ms'),
postBuildCommand: null, postBuildCommand: null,
installCommand: const <String>['scp', '-r', r'${localPath}', r'pi@raspberrypi:/tmp/${appName}'], installCommand: const <String>[
uninstallCommand: const <String>['ssh', 'pi@raspberrypi', r'rm -rf "/tmp/${appName}"'], 'scp',
runDebugCommand: const <String>['ssh', 'pi@raspberrypi', r'flutter-pi "/tmp/${appName}"'], '-r',
forwardPortCommand: const <String>['ssh', '-o', 'ExitOnForwardFailure=yes', '-L', r'127.0.0.1:${hostPort}:127.0.0.1:${devicePort}', 'pi@raspberrypi'], '-o', 'BatchMode=yes',
r'${localPath}',
r'pi@raspberrypi:/tmp/${appName}',
],
uninstallCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'pi@raspberrypi',
r'rm -rf "/tmp/${appName}"',
],
runDebugCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'pi@raspberrypi',
r'flutter-pi "/tmp/${appName}"',
],
forwardPortCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-L', r'127.0.0.1:${hostPort}:127.0.0.1:${devicePort}',
'pi@raspberrypi',
],
forwardPortSuccessRegex: RegExp('Linux'), forwardPortSuccessRegex: RegExp('Linux'),
screenshotCommand: const <String>['ssh', 'pi@raspberrypi', r"fbgrab /tmp/screenshot.png && cat /tmp/screenshot.png | base64 | tr -d ' \n\t'"] screenshotCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'pi@raspberrypi',
r"fbgrab /tmp/screenshot.png && cat /tmp/screenshot.png | base64 | tr -d ' \n\t'",
],
);
/// An example device config used for creating the default config file.
/// Uses ping and pingSuccessRegex values that only work on linux or macOs.
/// For the Windows example config, see [exampleWindows].
static final CustomDeviceConfig exampleUnix = exampleWindows.copyWith(
pingCommand: const <String>[
'ping',
'-w', '1',
'-c', '1',
'raspberrypi'
],
explicitPingSuccessRegex: true,
pingSuccessRegex: null
); );
/// Returns an example custom device config that works on the given host platform.
///
/// This is not the platform of the target device, it's the platform of the
/// development machine. Examples for different platforms may be different
/// because for example the ping command is different on Windows or Linux/macOS.
static CustomDeviceConfig getExampleForPlatform(Platform platform) {
if (platform.isWindows) {
return exampleWindows;
}
if (platform.isLinux || platform.isMacOS) {
return exampleUnix;
}
throw FallThroughError();
}
final String id; final String id;
final String label; final String label;
final String sdkNameAndVersion; final String sdkNameAndVersion;
final bool disabled; final TargetPlatform? platform;
final bool enabled;
final List<String> pingCommand; final List<String> pingCommand;
final RegExp? pingSuccessRegex; final RegExp? pingSuccessRegex;
final List<String>? postBuildCommand; final List<String>? postBuildCommand;
...@@ -91,28 +318,159 @@ class CustomDeviceConfig { ...@@ -91,28 +318,159 @@ class CustomDeviceConfig {
final RegExp? forwardPortSuccessRegex; final RegExp? forwardPortSuccessRegex;
final List<String>? screenshotCommand; final List<String>? screenshotCommand;
/// Returns true when this custom device config uses port forwarding,
/// which is the case when [forwardPortCommand] is not null.
bool get usesPortForwarding => forwardPortCommand != null; bool get usesPortForwarding => forwardPortCommand != null;
/// Returns true when this custom device config supports screenshotting,
/// which is the case when the [screenshotCommand] is not null.
bool get supportsScreenshotting => screenshotCommand != null; bool get supportsScreenshotting => screenshotCommand != null;
static List<String> _castStringList(Object object) { /// Invokes and returns the result of [closure].
return (object as List<dynamic>).cast<String>(); ///
/// If anything at all is thrown when executing the closure, a
/// [CustomDeviceRevivalException] is thrown with the given [fieldDescription] and
/// [expectedValueDescription].
static T _maybeRethrowAsRevivalException<T>(T Function() closure, String fieldDescription, String expectedValueDescription) {
try {
return closure();
} on Object {
throw CustomDeviceRevivalException.fromDescriptions(fieldDescription, expectedValueDescription);
}
}
/// Tries to make a string-keyed, non-null map from [value].
///
/// If the value is null or not a valid string-keyed map, a [CustomDeviceRevivalException]
/// with the given [fieldDescription] and [expectedValueDescription] is thrown.
static Map<String, dynamic> _castJsonObject(dynamic value, String fieldDescription, String expectedValueDescription) {
if (value == null) {
throw CustomDeviceRevivalException.fromDescriptions(fieldDescription, expectedValueDescription);
}
return _maybeRethrowAsRevivalException(
() => Map<String, dynamic>.from(value as Map<dynamic, dynamic>),
fieldDescription,
expectedValueDescription,
);
}
/// Tries to cast [value] to a bool.
///
/// If the value is null or not a bool, a [CustomDeviceRevivalException] with the given
/// [fieldDescription] and [expectedValueDescription] is thrown.
static bool _castBool(dynamic value, String fieldDescription, String expectedValueDescription) {
if (value == null) {
throw CustomDeviceRevivalException.fromDescriptions(fieldDescription, expectedValueDescription);
}
return _maybeRethrowAsRevivalException(
() => value as bool,
fieldDescription,
expectedValueDescription,
);
} }
static List<String>? _castStringListOrNull(Object? object) { /// Tries to cast [value] to a String.
return object == null ? null : _castStringList(object); ///
/// If the value is null or not a String, a [CustomDeviceRevivalException] with the given
/// [fieldDescription] and [expectedValueDescription] is thrown.
static String _castString(dynamic value, String fieldDescription, String expectedValueDescription) {
if (value == null) {
throw CustomDeviceRevivalException.fromDescriptions(fieldDescription, expectedValueDescription);
}
return _maybeRethrowAsRevivalException(
() => value as String,
fieldDescription,
expectedValueDescription,
);
} }
static RegExp? _convertToRegexOrNull(Object? object) { /// Tries to cast [value] to a nullable String.
return object == null ? null : RegExp(object as String); ///
/// If the value not null and not a String, a [CustomDeviceRevivalException] with the given
/// [fieldDescription] and [expectedValueDescription] is thrown.
static String? _castStringOrNull(dynamic value, String fieldDescription, String expectedValueDescription) {
if (value == null) {
return null;
}
return _castString(value, fieldDescription, expectedValueDescription);
} }
dynamic toJson() { /// Tries to make a list of strings from [value].
///
/// If the value is null or not a list containing only string values,
/// a [CustomDeviceRevivalException] with the given [fieldDescription] and
/// [expectedValueDescription] is thrown.
static List<String> _castStringList(
dynamic value,
String fieldDescription,
String expectedValueDescription, {
int minLength = 0,
}) {
if (value == null) {
throw CustomDeviceRevivalException.fromDescriptions(fieldDescription, expectedValueDescription);
}
final List<String> list = _maybeRethrowAsRevivalException(
() => List<String>.from(value as Iterable<dynamic>),
fieldDescription,
expectedValueDescription,
);
if (list.length < minLength) {
throw CustomDeviceRevivalException.fromDescriptions(fieldDescription, expectedValueDescription);
}
return list;
}
/// Tries to make a list of strings from [value], or returns null if [value]
/// is null.
///
/// If the value is not null and not a list containing only string values,
/// a [CustomDeviceRevivalException] with the given [fieldDescription] and
/// [expectedValueDescription] is thrown.
static List<String>? _castStringListOrNull(
dynamic value,
String fieldDescription,
String expectedValueDescription, {
int minLength = 0,
}) {
if (value == null) {
return null;
}
return _castStringList(value, fieldDescription, expectedValueDescription, minLength: minLength);
}
/// Tries to construct a RegExp from [value], or returns null if [value]
/// is null.
///
/// If the value is not null and not a valid string-ified regex,
/// a [CustomDeviceRevivalException] with the given [fieldDescription] and
/// [expectedValueDescription] is thrown.
static RegExp? _convertToRegexOrNull(dynamic value, String fieldDescription, String expectedValueDescription) {
if (value == null) {
return null;
}
return _maybeRethrowAsRevivalException(
() => RegExp(value as String),
fieldDescription,
expectedValueDescription,
);
}
Object toJson() {
return <String, Object?>{ return <String, Object?>{
_kId: id, _kId: id,
_kLabel: label, _kLabel: label,
_kSdkNameAndVersion: sdkNameAndVersion, _kSdkNameAndVersion: sdkNameAndVersion,
_kDisabled: disabled, _kPlatform: platform == null ? null : getNameForTargetPlatform(platform!),
_kEnabled: enabled,
_kPingCommand: pingCommand, _kPingCommand: pingCommand,
_kPingSuccessRegex: pingSuccessRegex?.pattern, _kPingSuccessRegex: pingSuccessRegex?.pattern,
_kPostBuildCommand: postBuildCommand, _kPostBuildCommand: postBuildCommand,
...@@ -129,7 +487,9 @@ class CustomDeviceConfig { ...@@ -129,7 +487,9 @@ class CustomDeviceConfig {
String? id, String? id,
String? label, String? label,
String? sdkNameAndVersion, String? sdkNameAndVersion,
bool? disabled, bool explicitPlatform = false,
TargetPlatform? platform,
bool? enabled,
List<String>? pingCommand, List<String>? pingCommand,
bool explicitPingSuccessRegex = false, bool explicitPingSuccessRegex = false,
RegExp? pingSuccessRegex, RegExp? pingSuccessRegex,
...@@ -149,7 +509,8 @@ class CustomDeviceConfig { ...@@ -149,7 +509,8 @@ class CustomDeviceConfig {
id: id ?? this.id, id: id ?? this.id,
label: label ?? this.label, label: label ?? this.label,
sdkNameAndVersion: sdkNameAndVersion ?? this.sdkNameAndVersion, sdkNameAndVersion: sdkNameAndVersion ?? this.sdkNameAndVersion,
disabled: disabled ?? this.disabled, platform: explicitPlatform ? platform : (platform ?? this.platform),
enabled: enabled ?? this.enabled,
pingCommand: pingCommand ?? this.pingCommand, pingCommand: pingCommand ?? this.pingCommand,
pingSuccessRegex: explicitPingSuccessRegex ? pingSuccessRegex : (pingSuccessRegex ?? this.pingSuccessRegex), pingSuccessRegex: explicitPingSuccessRegex ? pingSuccessRegex : (pingSuccessRegex ?? this.pingSuccessRegex),
postBuildCommand: explicitPostBuildCommand ? postBuildCommand : (postBuildCommand ?? this.postBuildCommand), postBuildCommand: explicitPostBuildCommand ? postBuildCommand : (postBuildCommand ?? this.postBuildCommand),
...@@ -161,4 +522,60 @@ class CustomDeviceConfig { ...@@ -161,4 +522,60 @@ class CustomDeviceConfig {
screenshotCommand: explicitScreenshotCommand ? screenshotCommand : (screenshotCommand ?? this.screenshotCommand), screenshotCommand: explicitScreenshotCommand ? screenshotCommand : (screenshotCommand ?? this.screenshotCommand),
); );
} }
@override
bool operator ==(Object other) {
return other is CustomDeviceConfig
&& other.id == id
&& other.label == label
&& other.sdkNameAndVersion == sdkNameAndVersion
&& other.platform == platform
&& other.enabled == enabled
&& _listsEqual(other.pingCommand, pingCommand)
&& _regexesEqual(other.pingSuccessRegex, pingSuccessRegex)
&& _listsEqual(other.postBuildCommand, postBuildCommand)
&& _listsEqual(other.installCommand, installCommand)
&& _listsEqual(other.uninstallCommand, uninstallCommand)
&& _listsEqual(other.runDebugCommand, runDebugCommand)
&& _listsEqual(other.forwardPortCommand, forwardPortCommand)
&& _regexesEqual(other.forwardPortSuccessRegex, forwardPortSuccessRegex)
&& _listsEqual(other.screenshotCommand, screenshotCommand);
}
@override
int get hashCode {
return id.hashCode
^ label.hashCode
^ sdkNameAndVersion.hashCode
^ platform.hashCode
^ enabled.hashCode
^ pingCommand.hashCode
^ (pingSuccessRegex?.pattern).hashCode
^ postBuildCommand.hashCode
^ installCommand.hashCode
^ uninstallCommand.hashCode
^ runDebugCommand.hashCode
^ forwardPortCommand.hashCode
^ (forwardPortSuccessRegex?.pattern).hashCode
^ screenshotCommand.hashCode;
}
@override
String toString() {
return 'CustomDeviceConfig('
'id: $id, '
'label: $label, '
'sdkNameAndVersion: $sdkNameAndVersion, '
'platform: $platform, '
'enabled: $enabled, '
'pingCommand: $pingCommand, '
'pingSuccessRegex: $pingSuccessRegex, '
'postBuildCommand: $postBuildCommand, '
'installCommand: $installCommand, '
'uninstallCommand: $uninstallCommand, '
'runDebugCommand: $runDebugCommand, '
'forwardPortCommand: $forwardPortCommand, '
'forwardPortSuccessRegex: $forwardPortSuccessRegex, '
'screenshotCommand: $screenshotCommand)';
}
} }
...@@ -21,44 +21,62 @@ class CustomDevicesConfig { ...@@ -21,44 +21,62 @@ class CustomDevicesConfig {
/// when it's not valid JSON (which other configurations do) and will not /// when it's not valid JSON (which other configurations do) and will not
/// be implicitly created when it doesn't exist. /// be implicitly created when it doesn't exist.
CustomDevicesConfig({ CustomDevicesConfig({
required Platform platform,
required FileSystem fileSystem, required FileSystem fileSystem,
required Logger logger, required Logger logger,
required Platform platform }) : _platform = platform,
}) : _fileSystem = fileSystem, _fileSystem = fileSystem,
_config = Config( _logger = logger,
_configLoader = (() => Config.managed(
_kCustomDevicesConfigName, _kCustomDevicesConfigName,
fileSystem: fileSystem, fileSystem: fileSystem,
logger: logger, logger: logger,
platform: platform, platform: platform,
deleteFileOnFormatException: false ));
)
{
ensureFileExists();
}
@visibleForTesting @visibleForTesting
CustomDevicesConfig.test({ CustomDevicesConfig.test({
required FileSystem fileSystem, required FileSystem fileSystem,
required Logger logger,
Directory? directory, Directory? directory,
required Logger logger Platform? platform,
}) : _fileSystem = fileSystem, }) : _platform = platform ?? FakePlatform(),
_config = Config.test( _fileSystem = fileSystem,
_logger = logger,
_configLoader = (() => Config.test(
name: _kCustomDevicesConfigName, name: _kCustomDevicesConfigName,
directory: directory, directory: directory,
logger: logger, logger: logger,
deleteFileOnFormatException: false managed: true
) ));
{
ensureFileExists();
}
static const String _kCustomDevicesConfigName = 'custom_devices.json'; static const String _kCustomDevicesConfigName = 'custom_devices.json';
static const String _kCustomDevicesConfigKey = 'custom-devices'; static const String _kCustomDevicesConfigKey = 'custom-devices';
static const String _kSchema = r'$schema'; static const String _kSchema = r'$schema';
static const String _kCustomDevices = 'custom-devices'; static const String _kCustomDevices = 'custom-devices';
final Platform _platform;
final FileSystem _fileSystem; final FileSystem _fileSystem;
final Config _config; final Logger _logger;
final Config Function() _configLoader;
// When the custom devices feature is disabled, CustomDevicesConfig is
// constructed anyway. So loading the config in the constructor isn't a good
// idea. (The Config ctor logs any errors)
//
// I also didn't want to introduce a FeatureFlags argument to the constructor
// and conditionally load the config when the feature is enabled, because
// sometimes we need that Config object even when the feature is disabled.
// For example inside ensureFileExists, which is used when enabling
// the feature.
//
// Instead, users of this config should handle the feature flags. So for
// example don't get [devices] when the feature is disabled.
Config? __config;
Config get _config {
__config ??= _configLoader();
return __config!;
}
String get _defaultSchema { String get _defaultSchema {
final Uri uri = _fileSystem final Uri uri = _fileSystem
...@@ -84,28 +102,123 @@ class CustomDevicesConfig { ...@@ -84,28 +102,123 @@ class CustomDevicesConfig {
void ensureFileExists() { void ensureFileExists() {
if (!_fileSystem.file(_config.configPath).existsSync()) { if (!_fileSystem.file(_config.configPath).existsSync()) {
_config.setValue(_kSchema, _defaultSchema); _config.setValue(_kSchema, _defaultSchema);
_config.setValue(_kCustomDevices, <dynamic>[CustomDeviceConfig.example.toJson()]); _config.setValue(_kCustomDevices, <dynamic>[
CustomDeviceConfig.getExampleForPlatform(_platform).toJson(),
]);
} }
} }
List<dynamic>? _getDevicesJsonValue() {
final dynamic json = _config.getValue(_kCustomDevicesConfigKey);
if (json == null) {
return null;
} else if (json is! List) {
const String msg = "Could not load custom devices config. config['$_kCustomDevicesConfigKey'] is not a JSON array.";
_logger.printError(msg);
throw const CustomDeviceRevivalException(msg);
}
return json;
}
/// Get the list of [CustomDeviceConfig]s that are listed in the config file /// Get the list of [CustomDeviceConfig]s that are listed in the config file
/// including disabled ones. /// including disabled ones.
/// ///
/// Returns an empty list when the config could not be loaded. /// Throws an Exception when the config could not be loaded and logs any
/// errors.
List<CustomDeviceConfig> get devices { List<CustomDeviceConfig> get devices {
final dynamic json = _config.getValue(_kCustomDevicesConfigKey); final List<dynamic>? typedListNullable = _getDevicesJsonValue();
if (typedListNullable == null) {
return <CustomDeviceConfig>[];
}
if (json == null) { final List<dynamic> typedList = typedListNullable;
final List<CustomDeviceConfig> revived = <CustomDeviceConfig>[];
for (final MapEntry<int, dynamic> entry in typedList.asMap().entries) {
try {
revived.add(CustomDeviceConfig.fromJson(entry.value));
} on CustomDeviceRevivalException catch (e) {
final String msg = 'Could not load custom device from config index ${entry.key}: $e';
_logger.printError(msg);
throw CustomDeviceRevivalException(msg);
}
}
return revived;
}
/// Get the list of [CustomDeviceConfig]s that are listed in the config file
/// including disabled ones.
///
/// Returns an empty list when the config could not be loaded and logs any
/// errors.
List<CustomDeviceConfig> tryGetDevices() {
try {
return devices;
} on Exception {
// any Exceptions are logged by [devices] already.
return <CustomDeviceConfig>[]; return <CustomDeviceConfig>[];
} }
}
/// Set the list of [CustomDeviceConfig]s in the config file.
///
/// It should generally be avoided to call this often, since this could mean
/// data loss. If you want to add or remove a device from the config,
/// consider using [add] or [remove].
set devices(List<CustomDeviceConfig> configs) {
_config.setValue(
_kCustomDevicesConfigKey,
configs.map<dynamic>((CustomDeviceConfig c) => c.toJson()).toList()
);
}
/// Add a custom device to the config file.
///
/// Works even when some of the custom devices in the config file are not
/// valid.
///
/// May throw a [CustomDeviceRevivalException] if `config['custom-devices']`
/// is not a list.
void add(CustomDeviceConfig config) {
_config.setValue(
_kCustomDevicesConfigKey,
<dynamic>[
...?_getDevicesJsonValue(),
config.toJson()
]
);
}
/// Returns true if the config file contains a device with id [deviceId].
bool contains(String deviceId) {
return devices.any((CustomDeviceConfig device) => device.id == deviceId);
}
final List<dynamic> typedList = json as List<dynamic>; /// Removes the first device with this device id from the config file.
///
/// Returns true if the device was successfully removed, false if a device
/// with this id could not be found.
bool remove(String deviceId) {
final List<CustomDeviceConfig> modifiedDevices = devices;
// we use this instead of filtering so we can detect if we actually removed
// anything.
final CustomDeviceConfig? device = modifiedDevices
.cast<CustomDeviceConfig?>()
.firstWhere((CustomDeviceConfig? d) => d!.id == deviceId,
orElse: () => null
);
if (device == null) {
return false;
}
return typedList.map((dynamic e) => CustomDeviceConfig.fromJson(e)).toList(); modifiedDevices.remove(device);
devices = modifiedDevices;
return true;
} }
// We don't have a setter for devices here because we don't need it and String get configPath => _config.configPath;
// it also may overwrite any things done by the user that aren't explicitly
// tracked by the JSON-representation. For example comments (not possible right now,
// but they'd be useful so maybe in the future) or formatting.
} }
...@@ -26,6 +26,7 @@ import 'base/time.dart'; ...@@ -26,6 +26,7 @@ import 'base/time.dart';
import 'base/user_messages.dart'; import 'base/user_messages.dart';
import 'build_system/build_system.dart'; import 'build_system/build_system.dart';
import 'cache.dart'; import 'cache.dart';
import 'custom_devices/custom_devices_config.dart';
import 'ios/ios_workflow.dart'; import 'ios/ios_workflow.dart';
import 'ios/plist_parser.dart'; import 'ios/plist_parser.dart';
import 'ios/xcodeproj.dart'; import 'ios/xcodeproj.dart';
...@@ -218,3 +219,5 @@ FlutterProjectFactory get projectFactory { ...@@ -218,3 +219,5 @@ FlutterProjectFactory get projectFactory {
fileSystem: fs, fileSystem: fs,
); );
} }
CustomDevicesConfig get customDevicesConfig => context.get<CustomDevicesConfig>()!;
...@@ -171,8 +171,12 @@ class FlutterCommandRunner extends CommandRunner<void> { ...@@ -171,8 +171,12 @@ class FlutterCommandRunner extends CommandRunner<void> {
Future<void> run(Iterable<String> args) { Future<void> run(Iterable<String> args) {
// Have an invocation of 'build' print out it's sub-commands. // Have an invocation of 'build' print out it's sub-commands.
// TODO(ianh): Move this to the Build command itself somehow. // TODO(ianh): Move this to the Build command itself somehow.
if (args.length == 1 && args.first == 'build') { if (args.length == 1) {
args = <String>['build', '-h']; if (args.first == 'build') {
args = <String>['build', '-h'];
} else if (args.first == 'custom-devices') {
args = <String>['custom-devices', '-h'];
}
} }
return super.run(args); return super.run(args);
......
...@@ -18,15 +18,27 @@ ...@@ -18,15 +18,27 @@
}, },
"label": { "label": {
"description": "A more descriptive, user-friendly label for the device.", "description": "A more descriptive, user-friendly label for the device.",
"type": "string" "type": "string",
"default": "",
"required": false
}, },
"sdkNameAndVersion": { "sdkNameAndVersion": {
"description": "Additional information about the device. For other devices, this is the SDK (for example Android SDK, Windows SDK) name and version.", "description": "Additional information about the device. For other devices, this is the SDK (for example Android SDK, Windows SDK) name and version.",
"type": "string" "type": "string",
"default": "",
"required": false
}, },
"disabled": { "enabled": {
"description": "If true, this device will be ignored completely by the flutter SDK and none of the commands configured will be called. You can use this as a way to comment out device configs you're still working on, for example.", "description": "If false, this device will be ignored completely by the flutter SDK and none of the commands configured will be called. You can use this as a way to comment out device configs you're still working on, for example.",
"type": "boolean" "type": "boolean",
"default": true,
"required": false
},
"platform": {
"description": "The platform of the target device.",
"enum": ["linux-arm64", "linux-x64"],
"default": "linux-arm64",
"required": false
}, },
"ping": { "ping": {
"description": "The command to be invoked to ping the device. Used to find out whether its available or not. Every exit code unequal 0 will be treated as \"unavailable\". On Windows, consider providing a \"pingSuccessRegex\" since Windows' ping will also return 0 on failure. Make sure the command times out internally since it's not guaranteed the flutter SDK will enforce a timeout itself.", "description": "The command to be invoked to ping the device. Used to find out whether its available or not. Every exit code unequal 0 will be treated as \"unavailable\". On Windows, consider providing a \"pingSuccessRegex\" since Windows' ping will also return 0 on failure. Make sure the command times out internally since it's not guaranteed the flutter SDK will enforce a timeout itself.",
...@@ -43,7 +55,7 @@ ...@@ -43,7 +55,7 @@
"description": "When the output of the ping command matches this regex (and the ping command finished with exit code 0), the ping will be considered successful and the pinged device reachable. If this regex is not provided the ping command will be considered successful when it returned with exit code 0. The regex syntax is the standard dart syntax.", "description": "When the output of the ping command matches this regex (and the ping command finished with exit code 0), the ping will be considered successful and the pinged device reachable. If this regex is not provided the ping command will be considered successful when it returned with exit code 0. The regex syntax is the standard dart syntax.",
"type": ["string", "null"], "type": ["string", "null"],
"format": "regex", "format": "regex",
"default": "ms TTL=", "default": "[<=]\\d+ms",
"required": false "required": false
}, },
"postBuild": { "postBuild": {
......
// 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.
// @dart = 2.8
import 'dart:async';
import 'dart:typed_data';
import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:file/memory.dart';
import 'package:file_testing/file_testing.dart';
import 'package:flutter_tools/src/base/context.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/os.dart';
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/base/terminal.dart';
import 'package:flutter_tools/src/base/user_messages.dart';
import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/commands/custom_devices.dart';
import 'package:flutter_tools/src/custom_devices/custom_device_config.dart';
import 'package:flutter_tools/src/custom_devices/custom_devices_config.dart';
import 'package:flutter_tools/src/runner/flutter_command_runner.dart';
import 'package:meta/meta.dart';
import 'package:process/process.dart';
import '../../src/common.dart';
import '../../src/context.dart';
import '../../src/fakes.dart';
import '../../src/test_flutter_command_runner.dart';
const String linuxFlutterRoot = '/flutter';
const String windowsFlutterRoot = r'C:\flutter';
const String defaultConfigLinux1 = r'''
{
"$schema": "file:///flutter/packages/flutter_tools/static/custom-devices.schema.json",
"custom-devices": [
{
"id": "pi",
"label": "Raspberry Pi",
"sdkNameAndVersion": "Raspberry Pi 4 Model B+",
"platform": "linux-arm64",
"enabled": false,
"ping": [
"ping",
"-w",
"1",
"-c",
"1",
"raspberrypi"
],
"pingSuccessRegex": null,
"postBuild": null,
"install": [
"scp",
"-r",
"-o",
"BatchMode=yes",
"${localPath}",
"pi@raspberrypi:/tmp/${appName}"
],
"uninstall": [
"ssh",
"-o",
"BatchMode=yes",
"pi@raspberrypi",
"rm -rf \"/tmp/${appName}\""
],
"runDebug": [
"ssh",
"-o",
"BatchMode=yes",
"pi@raspberrypi",
"flutter-pi \"/tmp/${appName}\""
],
"forwardPort": [
"ssh",
"-o",
"BatchMode=yes",
"-o",
"ExitOnForwardFailure=yes",
"-L",
"127.0.0.1:${hostPort}:127.0.0.1:${devicePort}",
"pi@raspberrypi"
],
"forwardPortSuccessRegex": "Linux",
"screenshot": [
"ssh",
"-o",
"BatchMode=yes",
"pi@raspberrypi",
"fbgrab /tmp/screenshot.png && cat /tmp/screenshot.png | base64 | tr -d ' \\n\\t'"
]
}
]
}
''';
const String defaultConfigLinux2 = r'''
{
"custom-devices": [
{
"id": "pi",
"label": "Raspberry Pi",
"sdkNameAndVersion": "Raspberry Pi 4 Model B+",
"platform": "linux-arm64",
"enabled": false,
"ping": [
"ping",
"-w",
"1",
"-c",
"1",
"raspberrypi"
],
"pingSuccessRegex": null,
"postBuild": null,
"install": [
"scp",
"-r",
"-o",
"BatchMode=yes",
"${localPath}",
"pi@raspberrypi:/tmp/${appName}"
],
"uninstall": [
"ssh",
"-o",
"BatchMode=yes",
"pi@raspberrypi",
"rm -rf \"/tmp/${appName}\""
],
"runDebug": [
"ssh",
"-o",
"BatchMode=yes",
"pi@raspberrypi",
"flutter-pi \"/tmp/${appName}\""
],
"forwardPort": [
"ssh",
"-o",
"BatchMode=yes",
"-o",
"ExitOnForwardFailure=yes",
"-L",
"127.0.0.1:${hostPort}:127.0.0.1:${devicePort}",
"pi@raspberrypi"
],
"forwardPortSuccessRegex": "Linux",
"screenshot": [
"ssh",
"-o",
"BatchMode=yes",
"pi@raspberrypi",
"fbgrab /tmp/screenshot.png && cat /tmp/screenshot.png | base64 | tr -d ' \\n\\t'"
]
}
],
"$schema": "file:///flutter/packages/flutter_tools/static/custom-devices.schema.json"
}
''';
final Platform linuxPlatform = FakePlatform(
operatingSystem: 'linux',
environment: <String, String>{
'FLUTTER_ROOT': linuxFlutterRoot,
'HOME': '/',
}
);
final Platform windowsPlatform = FakePlatform(
operatingSystem: 'windows',
environment: <String, String>{
'FLUTTER_ROOT': windowsFlutterRoot,
}
);
class FakeTerminal implements Terminal {
factory FakeTerminal({Platform platform}) {
return FakeTerminal._private(
stdio: FakeStdio(),
platform: platform
);
}
FakeTerminal._private({
this.stdio,
Platform platform
}) :
terminal = AnsiTerminal(
stdio: stdio,
platform: platform
);
final FakeStdio stdio;
final AnsiTerminal terminal;
void simulateStdin(String line) {
stdio.simulateStdin(line);
}
@override
set usesTerminalUi(bool value) => terminal.usesTerminalUi = value;
@override
bool get usesTerminalUi => terminal.usesTerminalUi;
@override
String bolden(String message) => terminal.bolden(message);
@override
String clearScreen() => terminal.clearScreen();
@override
String color(String message, TerminalColor color) => terminal.color(message, color);
@override
Stream<String> get keystrokes => terminal.keystrokes;
@override
Future<String> promptForCharInput(
List<String> acceptedCharacters, {
Logger logger,
String prompt,
int defaultChoiceIndex,
bool displayAcceptedCharacters = true
}) => terminal.promptForCharInput(
acceptedCharacters,
logger: logger,
prompt: prompt,
defaultChoiceIndex: defaultChoiceIndex,
displayAcceptedCharacters: displayAcceptedCharacters
);
@override
set singleCharMode(bool value) => terminal.singleCharMode = value;
@override
bool get stdinHasTerminal => terminal.stdinHasTerminal;
@override
String get successMark => terminal.successMark;
@override
bool get supportsColor => terminal.supportsColor;
@override
bool get supportsEmoji => terminal.supportsEmoji;
@override
String get warningMark => terminal.warningMark;
@override
int get preferredStyle => terminal.preferredStyle;
}
class FakeCommandRunner extends FlutterCommandRunner {
FakeCommandRunner({
@required Platform platform,
@required FileSystem fileSystem,
@required Logger logger,
UserMessages userMessages
}) : _platform = platform,
_fileSystem = fileSystem,
_logger = logger,
_userMessages = userMessages ?? UserMessages(),
assert(platform != null),
assert(fileSystem != null),
assert(logger != null);
final Platform _platform;
final FileSystem _fileSystem;
final Logger _logger;
final UserMessages _userMessages;
@override
Future<void> runCommand(ArgResults topLevelResults) async {
final Logger logger = (topLevelResults['verbose'] as bool) ? VerboseLogger(_logger) : _logger;
return context.run<void>(
overrides: <Type, Generator>{
Logger: () => logger
},
body: () {
Cache.flutterRoot ??= Cache.defaultFlutterRoot(
platform: _platform,
fileSystem: _fileSystem,
userMessages: _userMessages,
);
// For compatibility with tests that set this to a relative path.
Cache.flutterRoot = _fileSystem.path.normalize(_fileSystem.path.absolute(Cache.flutterRoot));
return super.runCommand(topLevelResults);
}
);
}
}
/// May take platform, logger, processManager and fileSystem from context if
/// not explicitly specified.
CustomDevicesCommand createCustomDevicesCommand({
CustomDevicesConfig Function(FileSystem, Logger) config,
Terminal Function(Platform) terminal,
Platform platform,
FileSystem fileSystem,
ProcessManager processManager,
Logger logger,
PrintFn usagePrintFn,
bool featureEnabled = false
}) {
platform ??= FakePlatform();
processManager ??= FakeProcessManager.any();
fileSystem ??= MemoryFileSystem.test();
usagePrintFn ??= print;
logger ??= BufferLogger.test();
return CustomDevicesCommand.test(
customDevicesConfig: config != null
? config(fileSystem, logger)
: CustomDevicesConfig.test(
platform: platform,
fileSystem: fileSystem,
directory: fileSystem.directory('/'),
logger: logger
),
operatingSystemUtils: FakeOperatingSystemUtils(
hostPlatform: platform.isLinux ? HostPlatform.linux_x64
: platform.isWindows ? HostPlatform.windows_x64
: platform.isMacOS ? HostPlatform.darwin_x64
: throw FallThroughError()
),
terminal: terminal != null
? terminal(platform)
: FakeTerminal(platform: platform),
platform: platform,
featureFlags: TestFeatureFlags(areCustomDevicesEnabled: featureEnabled),
processManager: processManager,
fileSystem: fileSystem,
logger: logger,
usagePrintFn: usagePrintFn,
);
}
/// May take platform, logger, processManager and fileSystem from context if
/// not explicitly specified.
CommandRunner<void> createCustomDevicesCommandRunner({
CustomDevicesConfig Function(FileSystem, Logger) config,
Terminal Function(Platform) terminal,
Platform platform,
FileSystem fileSystem,
ProcessManager processManager,
Logger logger,
PrintFn usagePrintFn,
bool featureEnabled = false,
}) {
platform ??= FakePlatform();
fileSystem ??= MemoryFileSystem.test();
logger ??= BufferLogger.test();
return FakeCommandRunner(
platform: platform,
fileSystem: fileSystem,
logger: logger
)..addCommand(
createCustomDevicesCommand(
config: config,
terminal: terminal,
platform: platform,
fileSystem: fileSystem,
processManager: processManager,
logger: logger,
usagePrintFn: usagePrintFn,
featureEnabled: featureEnabled
)
);
}
FakeTerminal createFakeTerminalForAddingSshDevice({
@required Platform platform,
@required String id,
@required String label,
@required String sdkNameAndVersion,
@required String enabled,
@required String hostname,
@required String username,
@required String runDebug,
@required String usePortForwarding,
@required String screenshot,
@required String apply
}) {
return FakeTerminal(platform: platform)
..simulateStdin(id)
..simulateStdin(label)
..simulateStdin(sdkNameAndVersion)
..simulateStdin(enabled)
..simulateStdin(hostname)
..simulateStdin(username)
..simulateStdin(runDebug)
..simulateStdin(usePortForwarding)
..simulateStdin(screenshot)
..simulateStdin(apply);
}
void main() {
const String featureNotEnabledMessage = 'Custom devices feature must be enabled. Enable using `flutter config --enable-custom-devices`.';
setUpAll(() {
Cache.disableLocking();
});
group('linux', () {
setUp(() {
Cache.flutterRoot = linuxFlutterRoot;
});
testUsingContext(
'custom-devices command shows config file in help when feature is enabled',
() async {
final BufferLogger logger = BufferLogger.test();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
logger: logger,
usagePrintFn: (Object o) => logger.printStatus(o.toString()),
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', '--help']),
completes
);
expect(
logger.statusText,
contains('Makes changes to the config file at "/.flutter_custom_devices.json".')
);
}
);
testUsingContext(
'running custom-devices command without arguments prints usage',
() async {
final BufferLogger logger = BufferLogger.test();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
logger: logger,
usagePrintFn: (Object o) => logger.printStatus(o.toString()),
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices']),
completes
);
expect(
logger.statusText,
contains('Makes changes to the config file at "/.flutter_custom_devices.json".')
);
}
);
// test behaviour with disabled feature
testUsingContext(
'custom-devices add command fails when feature is not enabled',
() async {
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
featureEnabled: false
);
expect(
runner.run(const <String>['custom-devices', 'add']),
throwsToolExit(message: featureNotEnabledMessage),
);
}
);
testUsingContext(
'custom-devices delete command fails when feature is not enabled',
() async {
final CommandRunner<void> runner = createCustomDevicesCommandRunner();
expect(
runner.run(const <String>['custom-devices', 'delete', '-d', 'testid']),
throwsToolExit(message: featureNotEnabledMessage),
);
}
);
testUsingContext(
'custom-devices list command fails when feature is not enabled',
() async {
final CommandRunner<void> runner = createCustomDevicesCommandRunner();
expect(
runner.run(const <String>['custom-devices', 'list']),
throwsToolExit(message: featureNotEnabledMessage),
);
}
);
testUsingContext(
'custom-devices reset command fails when feature is not enabled',
() async {
final CommandRunner<void> runner = createCustomDevicesCommandRunner();
expect(
runner.run(const <String>['custom-devices', 'reset']),
throwsToolExit(message: featureNotEnabledMessage),
);
}
);
// test add command
testUsingContext(
'custom-devices add command correctly adds ssh device config on linux',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
terminal: (Platform platform) => createFakeTerminalForAddingSshDevice(
platform: platform,
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: 'y',
hostname: 'testhostname',
username: 'testuser',
runDebug: 'testrundebug',
usePortForwarding: 'y',
screenshot: 'testscreenshot',
apply: 'y'
),
fileSystem: fs,
processManager: FakeProcessManager.any(),
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'add', '--no-check']),
completes
);
final CustomDevicesConfig config = CustomDevicesConfig.test(
fileSystem: fs,
directory: fs.directory('/'),
logger: BufferLogger.test()
);
expect(
config.devices,
contains(
CustomDeviceConfig(
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: true,
pingCommand: const <String>[
'ping',
'-c', '1',
'-w', '1',
'testhostname'
],
postBuildCommand: null,
installCommand: const <String>[
'scp',
'-r',
'-o', 'BatchMode=yes',
r'${localPath}',
r'testuser@testhostname:/tmp/${appName}'
],
uninstallCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
r'rm -rf "/tmp/${appName}"'
],
runDebugCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
'testrundebug'
],
forwardPortCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-L', r'127.0.0.1:${hostPort}:127.0.0.1:${devicePort}',
'testuser@testhostname'
],
forwardPortSuccessRegex: RegExp('Linux'),
screenshotCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
'testscreenshot'
],
)
)
);
}
);
testUsingContext(
'custom-devices add command correctly adds ipv4 ssh device config',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
terminal: (Platform platform) => createFakeTerminalForAddingSshDevice(
platform: platform,
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: 'y',
hostname: '192.168.178.1',
username: 'testuser',
runDebug: 'testrundebug',
usePortForwarding: 'y',
screenshot: 'testscreenshot',
apply: 'y',
),
processManager: FakeProcessManager.any(),
fileSystem: fs,
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'add', '--no-check']),
completes
);
final CustomDevicesConfig config = CustomDevicesConfig.test(
fileSystem: fs,
directory: fs.directory('/'),
logger: BufferLogger.test()
);
expect(
config.devices,
contains(
CustomDeviceConfig(
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: true,
pingCommand: const <String>[
'ping',
'-c', '1',
'-w', '1',
'192.168.178.1'
],
postBuildCommand: null,
installCommand: const <String>[
'scp',
'-r',
'-o', 'BatchMode=yes',
r'${localPath}',
r'testuser@192.168.178.1:/tmp/${appName}'
],
uninstallCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@192.168.178.1',
r'rm -rf "/tmp/${appName}"'
],
runDebugCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@192.168.178.1',
'testrundebug'
],
forwardPortCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-L', r'127.0.0.1:${hostPort}:127.0.0.1:${devicePort}',
'testuser@192.168.178.1'
],
forwardPortSuccessRegex: RegExp('Linux'),
screenshotCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@192.168.178.1',
'testscreenshot'
]
)
)
);
}
);
testUsingContext(
'custom-devices add command correctly adds ipv6 ssh device config',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
terminal: (Platform platform) => createFakeTerminalForAddingSshDevice(
platform: platform,
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: 'y',
hostname: '::1',
username: 'testuser',
runDebug: 'testrundebug',
usePortForwarding: 'y',
screenshot: 'testscreenshot',
apply: 'y',
),
fileSystem: fs,
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'add', '--no-check']),
completes
);
final CustomDevicesConfig config = CustomDevicesConfig.test(
fileSystem: fs,
directory: fs.directory('/'),
logger: BufferLogger.test()
);
expect(
config.devices,
contains(
CustomDeviceConfig(
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: true,
pingCommand: const <String>[
'ping',
'-6',
'-c', '1',
'-w', '1',
'::1'
],
postBuildCommand: null,
installCommand: const <String>[
'scp',
'-r',
'-o', 'BatchMode=yes',
'-6',
r'${localPath}',
r'testuser@[::1]:/tmp/${appName}'
],
uninstallCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'-6',
'testuser@[::1]',
r'rm -rf "/tmp/${appName}"'
],
runDebugCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'-6',
'testuser@[::1]',
'testrundebug'
],
forwardPortCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-6',
'-L', r'[::1]:${hostPort}:[::1]:${devicePort}',
'testuser@[::1]'
],
forwardPortSuccessRegex: RegExp('Linux'),
screenshotCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'-6',
'testuser@[::1]',
'testscreenshot'
]
)
)
);
}
);
testUsingContext(
'custom-devices add command correctly adds non-forwarding ssh device config',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
terminal: (Platform platform) => createFakeTerminalForAddingSshDevice(
platform: platform,
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: 'y',
hostname: 'testhostname',
username: 'testuser',
runDebug: 'testrundebug',
usePortForwarding: 'n',
screenshot: 'testscreenshot',
apply: 'y',
),
fileSystem: fs,
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'add', '--no-check']),
completes
);
final CustomDevicesConfig config = CustomDevicesConfig.test(
fileSystem: fs,
directory: fs.directory('/'),
logger: BufferLogger.test()
);
expect(
config.devices,
contains(
const CustomDeviceConfig(
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: true,
pingCommand: <String>[
'ping',
'-c', '1',
'-w', '1',
'testhostname'
],
postBuildCommand: null,
installCommand: <String>[
'scp',
'-r',
'-o', 'BatchMode=yes',
r'${localPath}',
r'testuser@testhostname:/tmp/${appName}'
],
uninstallCommand: <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
r'rm -rf "/tmp/${appName}"'
],
runDebugCommand: <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
'testrundebug'
],
screenshotCommand: <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
'testscreenshot'
]
)
)
);
}
);
testUsingContext(
'custom-devices add command correctly adds non-screenshotting ssh device config',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
terminal: (Platform platform) => createFakeTerminalForAddingSshDevice(
platform: platform,
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: 'y',
hostname: 'testhostname',
username: 'testuser',
runDebug: 'testrundebug',
usePortForwarding: 'y',
screenshot: '',
apply: 'y',
),
fileSystem: fs,
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'add', '--no-check']),
completes
);
final CustomDevicesConfig config = CustomDevicesConfig.test(
fileSystem: fs,
directory: fs.directory('/'),
logger: BufferLogger.test()
);
expect(
config.devices,
contains(
CustomDeviceConfig(
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: true,
pingCommand: const <String>[
'ping',
'-c', '1',
'-w', '1',
'testhostname'
],
postBuildCommand: null,
installCommand: const <String>[
'scp',
'-r',
'-o', 'BatchMode=yes',
r'${localPath}',
r'testuser@testhostname:/tmp/${appName}'
],
uninstallCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
r'rm -rf "/tmp/${appName}"'
],
runDebugCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
'testrundebug'
],
forwardPortCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-L', r'127.0.0.1:${hostPort}:127.0.0.1:${devicePort}',
'testuser@testhostname'
],
forwardPortSuccessRegex: RegExp('Linux'),
)
)
);
}
);
testUsingContext(
'custom-devices delete command deletes device and creates backup',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final CustomDevicesConfig config = CustomDevicesConfig.test(
fileSystem: fs,
directory: fs.directory('/'),
logger: BufferLogger.test(),
);
config.add(CustomDeviceConfig.exampleUnix.copyWith(id: 'testid'));
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
config: (_, __) => config,
fileSystem: fs,
featureEnabled: true
);
final Uint8List contentsBefore = fs.file('.flutter_custom_devices.json').readAsBytesSync();
await expectLater(
runner.run(const <String>['custom-devices', 'delete', '-d', 'testid']),
completes
);
expect(fs.file('/.flutter_custom_devices.json.bak'), exists);
expect(config.devices, hasLength(0));
final Uint8List backupContents = fs.file('.flutter_custom_devices.json.bak').readAsBytesSync();
expect(contentsBefore, equals(backupContents));
}
);
testUsingContext(
'custom-devices delete command without device argument throws tool exit',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final CustomDevicesConfig config = CustomDevicesConfig.test(
fileSystem: fs,
directory: fs.directory('/'),
logger: BufferLogger.test(),
);
config.add(CustomDeviceConfig.exampleUnix.copyWith(id: 'testid2'));
final Uint8List contentsBefore = fs.file('.flutter_custom_devices.json').readAsBytesSync();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'delete']),
throwsToolExit()
);
final Uint8List contentsAfter = fs.file('.flutter_custom_devices.json').readAsBytesSync();
expect(contentsBefore, equals(contentsAfter));
expect(fs.file('.flutter_custom_devices.json.bak').existsSync(), isFalse);
}
);
testUsingContext(
'custom-devices delete command throws tool exit with invalid device id',
() async {
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'delete', '-d', 'testid']),
throwsToolExit(message: 'Couldn\'t find device with id "testid" in config at "/.flutter_custom_devices.json"')
);
}
);
testUsingContext(
'custom-devices list command throws tool exit when config contains errors',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final BufferLogger logger = BufferLogger.test();
fs.file('.flutter_custom_devices.json').writeAsStringSync('{"custom-devices": {}}');
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
fileSystem: fs,
logger: logger,
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'list']),
throwsToolExit(message: 'Could not list custom devices.')
);
expect(
logger.errorText,
contains("Could not load custom devices config. config['custom-devices'] is not a JSON array.")
);
}
);
testUsingContext(
'custom-devices list command prints message when no devices found',
() async {
final BufferLogger logger = BufferLogger.test();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
logger: logger,
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'list']),
completes
);
expect(
logger.statusText,
contains('No custom devices found in "/.flutter_custom_devices.json"')
);
}
);
testUsingContext(
'custom-devices list command lists all devices',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final BufferLogger logger = BufferLogger.test();
CustomDevicesConfig.test(
fileSystem: fs,
directory: fs.directory('/'),
logger: logger,
)..add(
CustomDeviceConfig.exampleUnix.copyWith(id: 'testid', label: 'testlabel', enabled: true)
)..add(
CustomDeviceConfig.exampleUnix.copyWith(id: 'testid2', label: 'testlabel2', enabled: false)
);
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
logger: logger,
fileSystem: fs,
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'list']),
completes
);
expect(
logger.statusText,
contains('List of custom devices in "/.flutter_custom_devices.json":')
);
expect(
logger.statusText,
contains('id: testid, label: testlabel, enabled: true')
);
expect(
logger.statusText,
contains('id: testid2, label: testlabel2, enabled: false')
);
}
);
testUsingContext(
'custom-devices reset correctly backs up the config file',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final BufferLogger logger = BufferLogger.test();
CustomDevicesConfig.test(
fileSystem: fs,
directory: fs.directory('/'),
logger: logger,
)..add(
CustomDeviceConfig.exampleUnix.copyWith(id: 'testid', label: 'testlabel', enabled: true)
)..add(
CustomDeviceConfig.exampleUnix.copyWith(id: 'testid2', label: 'testlabel2', enabled: false)
);
final Uint8List contentsBefore = fs.file('.flutter_custom_devices.json').readAsBytesSync();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
logger: logger,
fileSystem: fs,
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'reset']),
completes
);
expect(
logger.statusText,
contains(
'Successfully resetted the custom devices config file and created a '
'backup at "/.flutter_custom_devices.json.bak".'
)
);
final Uint8List backupContents = fs.file('.flutter_custom_devices.json.bak').readAsBytesSync();
expect(contentsBefore, equals(backupContents));
expect(
fs.file('.flutter_custom_devices.json').readAsStringSync(),
anyOf(equals(defaultConfigLinux1), equals(defaultConfigLinux2))
);
}
);
testUsingContext(
"custom-devices reset outputs correct msg when config file didn't exist",
() async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final BufferLogger logger = BufferLogger.test();
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
logger: logger,
fileSystem: fs,
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'reset']),
completes
);
expect(
logger.statusText,
contains(
'Successfully resetted the custom devices config file.'
)
);
expect(fs.file('.flutter_custom_devices.json.bak'), isNot(exists));
expect(
fs.file('.flutter_custom_devices.json').readAsStringSync(),
anyOf(equals(defaultConfigLinux1), equals(defaultConfigLinux2))
);
}
);
});
group('windows', () {
setUp(() {
Cache.flutterRoot = windowsFlutterRoot;
});
testUsingContext(
'custom-devices add command correctly adds ssh device config on windows',
() async {
final MemoryFileSystem fs = MemoryFileSystem.test(style: FileSystemStyle.windows);
final CommandRunner<void> runner = createCustomDevicesCommandRunner(
terminal: (Platform platform) => createFakeTerminalForAddingSshDevice(
platform: platform,
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: 'y',
hostname: 'testhostname',
username: 'testuser',
runDebug: 'testrundebug',
usePortForwarding: 'y',
screenshot: 'testscreenshot',
apply: 'y',
),
fileSystem: fs,
platform: windowsPlatform,
featureEnabled: true
);
await expectLater(
runner.run(const <String>['custom-devices', 'add', '--no-check']),
completes
);
final CustomDevicesConfig config = CustomDevicesConfig.test(
fileSystem: fs,
directory: fs.directory('/'),
logger: BufferLogger.test()
);
expect(
config.devices,
contains(
CustomDeviceConfig(
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: true,
pingCommand: const <String>[
'ping',
'-n', '1',
'-w', '500',
'testhostname'
],
pingSuccessRegex: RegExp(r'[<=]\d+ms'),
postBuildCommand: null,
installCommand: const <String>[
'scp',
'-r',
'-o', 'BatchMode=yes',
r'${localPath}',
r'testuser@testhostname:/tmp/${appName}'
],
uninstallCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
r'rm -rf "/tmp/${appName}"'
],
runDebugCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
'testrundebug'
],
forwardPortCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-L', r'127.0.0.1:${hostPort}:127.0.0.1:${devicePort}',
'testuser@testhostname'
],
forwardPortSuccessRegex: RegExp('Linux'),
screenshotCommand: const <String>[
'ssh',
'-o', 'BatchMode=yes',
'testuser@testhostname',
'testscreenshot'
]
)
)
);
},
);
});
}
...@@ -99,7 +99,7 @@ void main() { ...@@ -99,7 +99,7 @@ void main() {
id: 'testid', id: 'testid',
label: 'testlabel', label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion', sdkNameAndVersion: 'testsdknameandversion',
disabled: false, enabled: true,
pingCommand: const <String>['testping'], pingCommand: const <String>['testping'],
pingSuccessRegex: RegExp('testpingsuccess'), pingSuccessRegex: RegExp('testpingsuccess'),
postBuildCommand: const <String>['testpostbuild'], postBuildCommand: const <String>['testpostbuild'],
...@@ -113,7 +113,7 @@ void main() { ...@@ -113,7 +113,7 @@ void main() {
const String testConfigPingSuccessOutput = 'testpingsuccess\n'; const String testConfigPingSuccessOutput = 'testpingsuccess\n';
const String testConfigForwardPortSuccessOutput = 'testforwardportsuccess\n'; const String testConfigForwardPortSuccessOutput = 'testforwardportsuccess\n';
final CustomDeviceConfig disabledTestConfig = testConfig.copyWith(disabled: true); final CustomDeviceConfig disabledTestConfig = testConfig.copyWith(enabled: false);
final CustomDeviceConfig testConfigNonForwarding = testConfig.copyWith( final CustomDeviceConfig testConfigNonForwarding = testConfig.copyWith(
explicitForwardPortCommand: true, explicitForwardPortCommand: true,
forwardPortCommand: null, forwardPortCommand: null,
...@@ -577,6 +577,18 @@ void main() { ...@@ -577,6 +577,18 @@ void main() {
expect(screenshotCommandWasExecuted, false); expect(screenshotCommandWasExecuted, false);
expect(screenshotFile.existsSync(), false); expect(screenshotFile.existsSync(), false);
}); });
testWithoutContext('CustomDevice returns correct target platform', () async {
final CustomDevice device = CustomDevice(
config: testConfig.copyWith(
platform: TargetPlatform.linux_x64
),
logger: BufferLogger.test(),
processManager: FakeProcessManager.empty()
);
expect(await device.targetPlatform, TargetPlatform.linux_x64);
});
} }
class FakeBundleBuilder extends Fake implements BundleBuilder { class FakeBundleBuilder extends Fake implements BundleBuilder {
......
// 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.
// @dart = 2.8
import 'package:file/memory.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/custom_devices/custom_device_config.dart';
import 'package:flutter_tools/src/custom_devices/custom_devices_config.dart';
import '../../src/common.dart';
import '../../src/custom_devices_common.dart';
Map<String, dynamic> copyJsonObjectWith(
Map<String, dynamic> object,
Map<String, dynamic> overrides
) => Map<String, dynamic>.of(object)..addAll(overrides);
void main() {
testWithoutContext("CustomDevicesConfig logs no error when 'custom-devices' key is missing in config", () {
final BufferLogger logger = BufferLogger.test();
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
final Directory directory = fileSystem.directory('custom_devices_config');
writeCustomDevicesConfigFile(
directory,
json: null
);
final CustomDevicesConfig customDevicesConfig = CustomDevicesConfig.test(
fileSystem: fileSystem,
directory: directory,
logger: logger
);
expect(customDevicesConfig.devices, hasLength(0));
expect(logger.errorText, hasLength(0));
});
testWithoutContext("CustomDevicesConfig logs error when 'custom-devices' key is not a JSON array", () {
final BufferLogger logger = BufferLogger.test();
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
final Directory directory = fileSystem.directory('custom_devices_config');
writeCustomDevicesConfigFile(
directory,
json: <String, dynamic>{
'test': 'testvalue'
}
);
final CustomDevicesConfig customDevicesConfig = CustomDevicesConfig.test(
fileSystem: fileSystem,
directory: directory,
logger: logger
);
const String msg = "Could not load custom devices config. config['custom-devices'] is not a JSON array.";
expect(() => customDevicesConfig.devices, throwsA(const CustomDeviceRevivalException(msg)));
expect(logger.errorText, contains(msg));
});
testWithoutContext('CustomDeviceRevivalException serialization', () {
expect(
const CustomDeviceRevivalException('testmessage').toString(),
equals('testmessage')
);
expect(
const CustomDeviceRevivalException.fromDescriptions('testfielddescription', 'testexpectedvaluedescription').toString(),
equals('Expected testfielddescription to be testexpectedvaluedescription.')
);
});
testWithoutContext('CustomDevicesConfig can load test config and logs no errors', () {
final BufferLogger logger = BufferLogger.test();
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
final Directory directory = fileSystem.directory('custom_devices_config');
writeCustomDevicesConfigFile(
directory,
json: <dynamic>[
testConfigJson
],
);
final CustomDevicesConfig customDevicesConfig = CustomDevicesConfig.test(
fileSystem: fileSystem,
directory: directory,
logger: logger
);
final List<CustomDeviceConfig> devices = customDevicesConfig.devices;
expect(logger.errorText, hasLength(0));
expect(devices, hasLength(1));
expect(devices.first, equals(testConfig));
});
testWithoutContext('CustomDevicesConfig logs error when id is null', () {
final BufferLogger logger = BufferLogger.test();
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
final Directory directory = fileSystem.directory('custom_devices_config');
writeCustomDevicesConfigFile(
directory,
json: <dynamic>[
copyJsonObjectWith(
testConfigJson,
<String, dynamic>{
'id': null
},
),
],
);
final CustomDevicesConfig customDevicesConfig = CustomDevicesConfig.test(
fileSystem: fileSystem,
directory: directory,
logger: logger
);
const String msg = 'Could not load custom device from config index 0: Expected id to be a string.';
expect(() => customDevicesConfig.devices, throwsA(const CustomDeviceRevivalException(msg)));
expect(logger.errorText, contains(msg));
});
testWithoutContext('CustomDevicesConfig logs error when id is not a string', () {
final BufferLogger logger = BufferLogger.test();
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
final Directory directory = fileSystem.directory('custom_devices_config');
writeCustomDevicesConfigFile(
directory,
json: <dynamic>[
copyJsonObjectWith(
testConfigJson,
<String, dynamic>{
'id': 1
},
),
],
);
final CustomDevicesConfig customDevicesConfig = CustomDevicesConfig.test(
fileSystem: fileSystem,
directory: directory,
logger: logger
);
const String msg = 'Could not load custom device from config index 0: Expected id to be a string.';
expect(() => customDevicesConfig.devices, throwsA(const CustomDeviceRevivalException(msg)));
expect(logger.errorText, contains(msg));
});
testWithoutContext('CustomDevicesConfig logs error when label is not a string', () {
final BufferLogger logger = BufferLogger.test();
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
final Directory directory = fileSystem.directory('custom_devices_config');
writeCustomDevicesConfigFile(
directory,
json: <dynamic>[
copyJsonObjectWith(
testConfigJson,
<String, dynamic>{
'label': 1
},
),
],
);
final CustomDevicesConfig customDevicesConfig = CustomDevicesConfig.test(
fileSystem: fileSystem,
directory: directory,
logger: logger
);
const String msg = 'Could not load custom device from config index 0: Expected label to be a string.';
expect(() => customDevicesConfig.devices, throwsA(const CustomDeviceRevivalException(msg)));
expect(logger.errorText, contains(msg));
});
testWithoutContext('CustomDevicesConfig loads config when postBuild is null', () {
final BufferLogger logger = BufferLogger.test();
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
final Directory directory = fileSystem.directory('custom_devices_config');
writeCustomDevicesConfigFile(
directory,
json: <dynamic>[
copyJsonObjectWith(
testConfigJson,
<String, dynamic>{
'postBuild': null
},
),
],
);
final CustomDevicesConfig customDevicesConfig = CustomDevicesConfig.test(
fileSystem: fileSystem,
directory: directory,
logger: logger,
);
expect(customDevicesConfig.devices, hasLength(1));
});
testWithoutContext('CustomDevicesConfig loads config without port forwarding', () {
final BufferLogger logger = BufferLogger.test();
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
final Directory directory = fileSystem.directory('custom_devices_config');
writeCustomDevicesConfigFile(
directory,
json: <dynamic>[
copyJsonObjectWith(
testConfigJson,
<String, dynamic>{
'forwardPort': null,
'forwardPortSuccessRegex': null
},
),
],
);
final CustomDevicesConfig customDevicesConfig = CustomDevicesConfig.test(
fileSystem: fileSystem,
directory: directory,
logger: logger,
);
final List<CustomDeviceConfig> devices = customDevicesConfig.devices;
expect(devices, hasLength(1));
expect(devices.first.usesPortForwarding, false);
});
testWithoutContext('CustomDevicesConfig logs error when port forward command is given but not regex', () {
final BufferLogger logger = BufferLogger.test();
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
final Directory directory = fileSystem.directory('custom_devices_config');
writeCustomDevicesConfigFile(
directory,
json: <dynamic>[
copyJsonObjectWith(
testConfigJson,
<String, dynamic>{
'forwardPortSuccessRegex': null
},
),
],
);
final CustomDevicesConfig customDevicesConfig = CustomDevicesConfig.test(
fileSystem: fileSystem,
directory: directory,
logger: logger,
);
const String msg = 'Could not load custom device from config index 0: When forwardPort is given, forwardPortSuccessRegex must be specified too.';
expect(() => customDevicesConfig.devices, throwsA(const CustomDeviceRevivalException(msg)));
expect(logger.errorText, contains(msg));
});
}
// 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.
// @dart = 2.8
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/convert.dart';
import 'package:flutter_tools/src/custom_devices/custom_device_config.dart';
void writeCustomDevicesConfigFile(
Directory dir, {
List<CustomDeviceConfig> configs,
dynamic json
}) {
dir.createSync(recursive: true);
final File file = dir.childFile('.flutter_custom_devices.json');
file.writeAsStringSync(jsonEncode(
<String, dynamic>{
'custom-devices': configs != null ?
configs.map<dynamic>((CustomDeviceConfig c) => c.toJson()).toList() :
json
},
));
}
final CustomDeviceConfig testConfig = CustomDeviceConfig(
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
enabled: true,
pingCommand: const <String>['testping'],
pingSuccessRegex: RegExp('testpingsuccess'),
postBuildCommand: const <String>['testpostbuild'],
installCommand: const <String>['testinstall'],
uninstallCommand: const <String>['testuninstall'],
runDebugCommand: const <String>['testrundebug'],
forwardPortCommand: const <String>['testforwardport'],
forwardPortSuccessRegex: RegExp('testforwardportsuccess')
);
const String testConfigPingSuccessOutput = 'testpingsuccess\n';
const String testConfigForwardPortSuccessOutput = 'testforwardportsuccess\n';
final CustomDeviceConfig disabledTestConfig = testConfig.copyWith(enabled: false);
final CustomDeviceConfig testConfigNonForwarding = testConfig.copyWith(
explicitForwardPortCommand: true,
forwardPortCommand: null,
explicitForwardPortSuccessRegex: true,
forwardPortSuccessRegex: null,
);
const Map<String, dynamic> testConfigJson = <String, dynamic>{
'id': 'testid',
'label': 'testlabel',
'sdkNameAndVersion': 'testsdknameandversion',
'enabled': true,
'ping': <String>['testping'],
'pingSuccessRegex': 'testpingsuccess',
'postBuild': <String>['testpostbuild'],
'install': <String>['testinstall'],
'uninstall': <String>['testuninstall'],
'runDebug': <String>['testrundebug'],
'forwardPort': <String>['testforwardport'],
'forwardPortSuccessRegex': 'testforwardportsuccess'
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment