custom_devices.dart 26 KB
Newer Older
1 2 3 4
// 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.

5

6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34

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({
35 36 37 38 39 40 41 42
    required CustomDevicesConfig customDevicesConfig,
    required OperatingSystemUtils operatingSystemUtils,
    required Terminal terminal,
    required Platform platform,
    required ProcessManager processManager,
    required FileSystem fileSystem,
    required Logger logger,
    required FeatureFlags featureFlags,
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
  }) {
    return CustomDevicesCommand._common(
      customDevicesConfig: customDevicesConfig,
      operatingSystemUtils: operatingSystemUtils,
      terminal: terminal,
      platform: platform,
      processManager: processManager,
      fileSystem: fileSystem,
      logger: logger,
      featureFlags: featureFlags
    );
  }

  @visibleForTesting
  factory CustomDevicesCommand.test({
58 59 60 61 62 63 64 65
    required CustomDevicesConfig customDevicesConfig,
    required OperatingSystemUtils operatingSystemUtils,
    required Terminal terminal,
    required Platform platform,
    required ProcessManager processManager,
    required FileSystem fileSystem,
    required Logger logger,
    required FeatureFlags featureFlags,
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
    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({
82 83 84 85 86 87 88 89
    required CustomDevicesConfig customDevicesConfig,
    required OperatingSystemUtils operatingSystemUtils,
    required Terminal terminal,
    required Platform platform,
    required ProcessManager processManager,
    required FileSystem fileSystem,
    required Logger logger,
    required FeatureFlags featureFlags,
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160
    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';

161 162 163
  @override
  String get category => FlutterCommandCategory.tools;

164
  @override
165 166 167
  Future<FlutterCommandResult> runCommand() async {
    return FlutterCommandResult.success();
  }
168 169 170 171 172 173 174 175 176 177 178 179

  @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({
180 181 182 183
    required this.customDevicesConfig,
    required this.featureFlags,
    required this.fileSystem,
    required this.logger,
184 185 186 187
  });

  @protected final CustomDevicesConfig customDevicesConfig;
  @protected final FeatureFlags featureFlags;
188
  @protected final FileSystem? fileSystem;
189 190 191 192 193 194 195 196 197 198 199
  @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() {
200
    final File configFile = fileSystem!.file(customDevicesConfig.configPath);
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
    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({
224 225 226
    required super.customDevicesConfig,
    required super.featureFlags,
    required super.logger,
227
  }) : super(
228
         fileSystem: null
229 230 231 232 233 234 235 236 237 238 239 240 241 242
       );

  @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();

243
    late List<CustomDeviceConfig> devices;
244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264
    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({
265 266 267 268 269
    required super.customDevicesConfig,
    required super.featureFlags,
    required FileSystem super.fileSystem,
    required super.logger,
  });
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287

  @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();

288
    ErrorHandlingFileSystem.deleteIfExists(fileSystem!.file(customDevicesConfig.configPath));
289 290 291 292 293 294 295 296 297 298 299 300 301 302
    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({
303 304 305 306 307 308 309 310
    required super.customDevicesConfig,
    required OperatingSystemUtils operatingSystemUtils,
    required Terminal terminal,
    required Platform platform,
    required super.featureFlags,
    required ProcessManager processManager,
    required FileSystem super.fileSystem,
    required super.logger,
311 312 313
  }) : _operatingSystemUtils = operatingSystemUtils,
       _terminal = terminal,
       _platform = platform,
314
       _processManager = processManager
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360
  {
    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';

361
  // A hostname consists of one or more "names", separated by a dot.
362 363 364 365 366 367 368 369
  // 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;
370
  late StreamQueue<String> inputs;
371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403

  @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;
    }

404
    final Directory temp = await fileSystem!.systemTempDirectory.createTemp();
405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435

    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,
436 437
        forwardPortCommand: config.forwardPortCommand!,
        forwardPortSuccessRegex: config.forwardPortSuccessRegex!,
438 439 440 441 442 443 444 445
        processManager: _processManager,
        logger: logger,
      );

      try {
        // find a random port we can forward
        final int port = await _operatingSystemUtils.findFreePort();

446
        final ForwardedPort forwardedPort = await (portForwarder.tryForward(port, port) as FutureOr<ForwardedPort>);
447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472
        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 {
473 474
    final String jsonStr = stringArgDeprecated(_kJson)!;
    final bool shouldCheck = boolArgDeprecated(_kCheck);
475 476 477 478 479 480 481 482

    dynamic json;
    try {
      json = jsonDecode(jsonStr);
    } on FormatException catch (e) {
      throwToolExit('Could not decode json: $e');
    }

483
    late CustomDeviceConfig config;
484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508
    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.
509
  Future<String?> askForString(
510
    String name, {
511 512 513 514
    String? description,
    String? example,
    String? defaultsTo,
    Future<bool> Function(String)? validator,
515 516 517 518 519
  }) async {
    String msg = description ?? name;

    final String exampleOrDefault = <String>[
      if (example != null) 'example: $example',
520
      if (defaultsTo != null) 'empty for $defaultsTo',
521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545
    ].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, {
546
    String? description,
547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581
    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 {
582
    final bool shouldCheck = boolArgDeprecated(_kCheck);
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600

    // 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);

601
    final String id = await (askForString(
602 603 604 605 606 607
      '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),
608
    ) as FutureOr<String>);
609

610
    final String label = await (askForString(
611 612 613 614 615
      'label',
      description:
        'Please enter the label of the device, which is a slightly more verbose '
        'name for the device.',
      example: 'Raspberry Pi',
616
    ) as FutureOr<String>);
617

618
    final String sdkNameAndVersion = await (askForString(
619 620
      'SDK name and version',
      example: 'Raspberry Pi 4 Model B+',
621
    ) as FutureOr<String>);
622 623 624 625 626 627

    final bool enabled = await askForBool(
      'enabled',
      description: 'Should the device be enabled?',
    );

628
    final String targetStr = await (askForString(
629 630 631 632
      'target',
      description: 'Please enter the hostname or IPv4/v6 address of the device.',
      example: 'raspberrypi',
      validator: (String s) async => _isValidHostname(s) || _isValidIpAddr(s)
633
    ) as FutureOr<String>);
634

635
    final InternetAddress? targetIp = InternetAddress.tryParse(targetStr);
636 637 638 639 640 641
    final bool useIp = targetIp != null;
    final bool ipv6 = useIp && targetIp.type == InternetAddressType.IPv6;
    final InternetAddress loopbackIp = ipv6
      ? InternetAddress.loopbackIPv6
      : InternetAddress.loopbackIPv4;

642
    final String username = await (askForString(
643 644 645 646
      'username',
      description: 'Please enter the username used for ssh-ing into the remote device.',
      example: 'pi',
      defaultsTo: 'no username',
647
    ) as FutureOr<String>);
648

649
    final String remoteRunDebugCommand = await (askForString(
650 651 652 653 654
      '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}'
655
    ) as FutureOr<String>);
656 657 658 659 660 661 662 663 664 665

    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.',
    );

666
    final String screenshotCommand = await (askForString(
667 668 669 670
      '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',
671
    ) as FutureOr<String>);
672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691

    // 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>[],

692
      postBuildCommand: const <String>[],
693 694 695 696 697 698 699 700

      // just install to /tmp/${appName} by default
      installCommand: <String>[
        'scp',
        '-r',
        '-o', 'BatchMode=yes',
        if (ipv6) '-6',
        r'${localPath}',
701
        '$sshTarget:/tmp/\${appName}',
702 703 704 705 706 707 708
      ],

      uninstallCommand: <String>[
        'ssh',
        '-o', 'BatchMode=yes',
        if (ipv6) '-6',
        sshTarget,
709
        r'rm -rf "/tmp/${appName}"',
710 711 712 713 714 715 716
      ],

      runDebugCommand: <String>[
        'ssh',
        '-o', 'BatchMode=yes',
        if (ipv6) '-6',
        sshTarget,
717
        remoteRunDebugCommand,
718 719 720 721 722 723 724 725 726
      ],

      forwardPortCommand: usePortForwarding
        ? <String>[
          'ssh',
          '-o', 'BatchMode=yes',
          '-o', 'ExitOnForwardFailure=yes',
          if (ipv6) '-6',
          '-L', '$formattedLoopbackIp:\${hostPort}:$formattedLoopbackIp:\${devicePort}',
727
          sshTarget,
728
          "echo 'Port forwarding success'; read",
729 730 731
        ]
        : null,
      forwardPortSuccessRegex: usePortForwarding
732
        ? RegExp('Port forwarding success')
733 734 735 736 737 738 739 740
        : null,

      screenshotCommand: screenshotCommand.isNotEmpty
        ? <String>[
          'ssh',
          '-o', 'BatchMode=yes',
          if (ipv6) '-6',
          sshTarget,
741
          screenshotCommand,
742 743 744 745 746 747 748 749 750 751 752
        ]
        : null
    );

    if (_platform.isWindows) {
      config = config.copyWith(
        pingCommand: <String>[
          'ping',
          if (ipv6) '-6',
          '-n', '1',
          '-w', '500',
753
          targetStr,
754 755 756 757 758 759 760 761 762 763 764
        ],
        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',
765
          targetStr,
766 767 768 769
        ],
        explicitPingSuccessRegex: true,
      );
    } else {
770
      throw UnsupportedError('Unsupported operating system');
771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792
    }

    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();

793
    if (stringArgDeprecated(_kJson) != null) {
794 795
      return runNonInteractively();
    }
796
    if (boolArgDeprecated(_kSsh) == true) {
797 798
      return runInteractivelySsh();
    }
799
    throw UnsupportedError('Unknown run mode');
800 801 802 803 804
  }
}

class CustomDevicesDeleteCommand extends CustomDevicesCommandBase {
  CustomDevicesDeleteCommand({
805 806 807 808 809
    required super.customDevicesConfig,
    required super.featureFlags,
    required FileSystem super.fileSystem,
    required super.logger,
  });
810 811 812 813 814 815 816 817 818 819 820 821 822

  @override
  String get description => '''
Delete a device from the config file.
''';

  @override
  String get name => 'delete';

  @override
  Future<FlutterCommandResult> runCommand() async {
    checkFeatureEnabled();

823
    final String id = globalResults!['device-id'] as String;
824 825 826 827 828 829 830 831 832 833
    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();
  }
}