// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '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. /// /// In the custom devices config file on disk, there may be multiple custom /// devices configured. @immutable class CustomDeviceConfig { const CustomDeviceConfig({ required this.id, required this.label, required this.sdkNameAndVersion, this.platform, required this.enabled, required this.pingCommand, this.pingSuccessRegex, required this.postBuildCommand, required this.installCommand, required this.uninstallCommand, required this.runDebugCommand, this.forwardPortCommand, this.forwardPortSuccessRegex, this.screenshotCommand }) : 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) { 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 UnsupportedError { 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( id: _castString(typedMap[_kId], _kId, 'a string'), label: _castString(typedMap[_kLabel], _kLabel, 'a string'), sdkNameAndVersion: _castString(typedMap[_kSdkNameAndVersion], _kSdkNameAndVersion, 'a string'), platform: platform, enabled: _castBool(typedMap[_kEnabled], _kEnabled, 'a boolean'), pingCommand: _castStringList( typedMap[_kPingCommand], _kPingCommand, 'array of strings with at least one element', minLength: 1 ), pingSuccessRegex: _convertToRegexOrNull(typedMap[_kPingSuccessRegex], _kPingSuccessRegex, 'null or string-ified regex'), 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 _kLabel = 'label'; static const String _kSdkNameAndVersion = 'sdkNameAndVersion'; static const String _kPlatform = 'platform'; static const String _kEnabled = 'enabled'; static const String _kPingCommand = 'ping'; static const String _kPingSuccessRegex = 'pingSuccessRegex'; static const String _kPostBuildCommand = 'postBuild'; static const String _kInstallCommand = 'install'; static const String _kUninstallCommand = 'uninstall'; static const String _kRunDebugCommand = 'runDebug'; static const String _kForwardPortCommand = 'forwardPort'; static const String _kForwardPortSuccessRegex = 'forwardPortSuccessRegex'; static const String _kScreenshotCommand = 'screenshot'; /// An example device config used for creating the default config file. /// Uses windows-specific ping and pingSuccessRegex. For the linux and macOs /// example config, see [exampleUnix]. static final CustomDeviceConfig exampleWindows = CustomDeviceConfig( id: 'pi', label: 'Raspberry Pi', sdkNameAndVersion: 'Raspberry Pi 4 Model B+', platform: TargetPlatform.linux_arm64, enabled: false, pingCommand: const <String>[ 'ping', '-w', '500', '-n', '1', 'raspberrypi', ], pingSuccessRegex: RegExp(r'[<=]\d+ms'), postBuildCommand: null, installCommand: const <String>[ 'scp', '-r', '-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', "echo 'Port forwarding success'; read", ], forwardPortSuccessRegex: RegExp('Port forwarding success'), 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 ); /// 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 UnsupportedError('Unsupported operating system'); } final String id; final String label; final String sdkNameAndVersion; final TargetPlatform? platform; final bool enabled; final List<String> pingCommand; final RegExp? pingSuccessRegex; final List<String>? postBuildCommand; final List<String> installCommand; final List<String> uninstallCommand; final List<String> runDebugCommand; final List<String>? forwardPortCommand; final RegExp? forwardPortSuccessRegex; 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; /// Returns true when this custom device config supports screenshotting, /// which is the case when the [screenshotCommand] is not null. bool get supportsScreenshotting => screenshotCommand != null; /// Invokes and returns the result of [closure]. /// /// 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, ); } /// Tries to cast [value] to a String. /// /// 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, ); } /// Tries to cast [value] to a nullable 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); } /// 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?>{ _kId: id, _kLabel: label, _kSdkNameAndVersion: sdkNameAndVersion, _kPlatform: platform == null ? null : getNameForTargetPlatform(platform!), _kEnabled: enabled, _kPingCommand: pingCommand, _kPingSuccessRegex: pingSuccessRegex?.pattern, _kPostBuildCommand: (postBuildCommand?.length ?? 0) > 0 ? postBuildCommand : null, _kInstallCommand: installCommand, _kUninstallCommand: uninstallCommand, _kRunDebugCommand: runDebugCommand, _kForwardPortCommand: forwardPortCommand, _kForwardPortSuccessRegex: forwardPortSuccessRegex?.pattern, _kScreenshotCommand: screenshotCommand, }; } CustomDeviceConfig copyWith({ String? id, String? label, String? sdkNameAndVersion, bool explicitPlatform = false, TargetPlatform? platform, bool? enabled, List<String>? pingCommand, bool explicitPingSuccessRegex = false, RegExp? pingSuccessRegex, bool explicitPostBuildCommand = false, List<String>? postBuildCommand, List<String>? installCommand, List<String>? uninstallCommand, List<String>? runDebugCommand, bool explicitForwardPortCommand = false, List<String>? forwardPortCommand, bool explicitForwardPortSuccessRegex = false, RegExp? forwardPortSuccessRegex, bool explicitScreenshotCommand = false, List<String>? screenshotCommand }) { return CustomDeviceConfig( id: id ?? this.id, label: label ?? this.label, sdkNameAndVersion: sdkNameAndVersion ?? this.sdkNameAndVersion, platform: explicitPlatform ? platform : (platform ?? this.platform), enabled: enabled ?? this.enabled, pingCommand: pingCommand ?? this.pingCommand, pingSuccessRegex: explicitPingSuccessRegex ? pingSuccessRegex : (pingSuccessRegex ?? this.pingSuccessRegex), postBuildCommand: explicitPostBuildCommand ? postBuildCommand : (postBuildCommand ?? this.postBuildCommand), installCommand: installCommand ?? this.installCommand, uninstallCommand: uninstallCommand ?? this.uninstallCommand, runDebugCommand: runDebugCommand ?? this.runDebugCommand, forwardPortCommand: explicitForwardPortCommand ? forwardPortCommand : (forwardPortCommand ?? this.forwardPortCommand), forwardPortSuccessRegex: explicitForwardPortSuccessRegex ? forwardPortSuccessRegex : (forwardPortSuccessRegex ?? this.forwardPortSuccessRegex), 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)'; } }