From 09bfca66019409a6c82cf69c055462f720e24fa8 Mon Sep 17 00:00:00 2001 From: dungngminh <63831488+dungngminh@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:59:16 +0700 Subject: [PATCH 1/2] feat: wipe or delete emulators and simulators from TUI and CLI TUI: `d` wipes a shut-down emulator/simulator's data, `D` deletes it, both behind a confirmation dialog. CLI: `simutil wipe ` and `simutil delete ` ask y/N first; `-y/--yes` skips the prompt. Physical and running devices are refused. Closes #50 --- README.md | 2 + packages/simutil/CHANGELOG.md | 7 + packages/simutil/lib/src/cli/cli_catalog.dart | 28 ++++ .../lib/src/cli/cli_device_services.dart | 8 ++ .../commands/destructive_device_commands.dart | 129 ++++++++++++++++++ .../lib/src/cli/simutil_command_runner.dart | 3 + .../lib/src/tui/app/simutil_tui_app.dart | 76 +++++++++++ .../tui/components/device_list_component.dart | 18 +++ .../cli/destructive_device_commands_test.dart | 66 +++++++++ .../test/cli/simutil_command_runner_test.dart | 2 + .../device_list_component_test.dart | 38 ++++++ 11 files changed, 377 insertions(+) create mode 100644 packages/simutil/lib/src/cli/commands/destructive_device_commands.dart create mode 100644 packages/simutil/test/cli/destructive_device_commands_test.dart create mode 100644 packages/simutil/test/tui/components/device_list_component_test.dart diff --git a/README.md b/README.md index f5e1b77..f317a90 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ simutil launch # boot (alias: start) simutil launch -c --no-audio # cold boot, no audio (Android) simutil launch --headless # no emulator window / Simulator app simutil shutdown # alias: stop +simutil wipe # erase data of a shut-down device (asks y/N) +simutil delete -y # delete it; -y skips the prompt simutil android device list -r # running adb devices only simutil plugin run scrcpy mirror -d emulator-5554 simutil schema # every command as JSON, for agents diff --git a/packages/simutil/CHANGELOG.md b/packages/simutil/CHANGELOG.md index d2136cb..97884f8 100644 --- a/packages/simutil/CHANGELOG.md +++ b/packages/simutil/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Press `d` to wipe a shut-down emulator or simulator's data, or `D` to delete + it, after a confirmation prompt. +- `simutil wipe ` and `simutil delete ` do the same from the command + line; they ask y/N first, `--yes` skips the question. + ### Fixed - Connected iPhones and iPads show up again in the device list. diff --git a/packages/simutil/lib/src/cli/cli_catalog.dart b/packages/simutil/lib/src/cli/cli_catalog.dart index 9ceab9e..a9729a3 100644 --- a/packages/simutil/lib/src/cli/cli_catalog.dart +++ b/packages/simutil/lib/src/cli/cli_catalog.dart @@ -79,6 +79,28 @@ List simutilCliCatalog({required String version}) => [ flags: cliPlatformFlags, examples: ['simutil shutdown emulator-5554', 'simutil stop -i'], ), + CliCommandSpec( + name: 'wipe', + summary: 'Erase a shut-down emulator or simulator\'s data (asks y/N)', + usage: 'simutil wipe [options]', + arguments: ['device-id'], + flags: [...cliPlatformFlags, _yesFlag], + examples: [ + 'simutil wipe Pixel_7_Pro_big_Android_15', + 'simutil wipe -y', + ], + ), + CliCommandSpec( + name: 'delete', + summary: 'Delete a shut-down emulator or simulator (asks y/N)', + usage: 'simutil delete [options]', + arguments: ['device-id'], + flags: [...cliPlatformFlags, _yesFlag], + examples: [ + 'simutil delete Pixel_7_Pro_big_Android_15', + 'simutil delete -i -y', + ], + ), _pluginCommand(), CliCommandSpec( name: 'schema', @@ -290,6 +312,12 @@ enum CliArgKind { option, } +const _yesFlag = CliFlagSpec( + short: 'y', + long: 'yes', + description: 'Skip the confirmation prompt', +); + /// Documents one flag or option for help text and agent schema export. class CliFlagSpec { /// Creates flag documentation. diff --git a/packages/simutil/lib/src/cli/cli_device_services.dart b/packages/simutil/lib/src/cli/cli_device_services.dart index 6f490bf..0449859 100644 --- a/packages/simutil/lib/src/cli/cli_device_services.dart +++ b/packages/simutil/lib/src/cli/cli_device_services.dart @@ -85,6 +85,14 @@ class CliDeviceServices { Future shutdownDevice(Device device) => _serviceFor(device).shutdownSimulator(deviceId: device.id); + /// Erases a shut-down simulator/emulator's user data. + Future wipeDevice(Device device) => + _serviceFor(device).wipeData(deviceId: device.id); + + /// Permanently deletes a shut-down simulator/emulator. + Future deleteDevice(Device device) => + _serviceFor(device).deleteSimulator(deviceId: device.id); + DeviceService _serviceFor(Device device) => _services[device.os] ?? cliUsage('No service registered for ${device.os.name}'); diff --git a/packages/simutil/lib/src/cli/commands/destructive_device_commands.dart b/packages/simutil/lib/src/cli/commands/destructive_device_commands.dart new file mode 100644 index 0000000..e2d2d03 --- /dev/null +++ b/packages/simutil/lib/src/cli/commands/destructive_device_commands.dart @@ -0,0 +1,129 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:simutil/src/cli/cli_device_services.dart'; +import 'package:simutil/src/cli/cli_flags.dart'; +import 'package:simutil/src/cli/commands/simutil_command.dart'; +import 'package:simutil_core/simutil_core.dart'; + +/// Shared flow for commands that destroy a shut-down emulator / simulator's +/// data: resolve the id, refuse physical or running devices, ask y/N unless +/// `--yes`, then run [perform]. +abstract class _DestructiveDeviceCommand extends SimutilCommand { + _DestructiveDeviceCommand({super.logger, CliDeviceServices? services}) + : services = services ?? CliDeviceServices(); + + final CliDeviceServices services; + + /// Verb for messages, e.g. `wipe`. + String get verb; + + /// Past tense for the success message, e.g. `Wiped`. + String get done; + + /// The y/N prompt for [device]. + String prompt(Device device); + + /// Runs the action; returns whether it succeeded. + Future perform(Device device); + + @override + ArgParser get argParser => configuredArgParser((parser) { + addPlatformFlags(parser); + parser.addFlag( + 'yes', + abbr: 'y', + help: 'Skip the confirmation prompt (-y, --yes)', + negatable: false, + ); + }); + + @override + Future run() async { + final rest = argResults!.rest; + if (rest.isEmpty) { + throw UsageException('Missing device id.', usage); + } + if (rest.length > 1) { + throw UsageException('Expected a single device id.', usage); + } + + final device = await services.resolveDevice( + rest.first, + osHint: osHintFromFlags(argResults!), + ); + final label = '${device.name} (${device.id})'; + if (device.type.isPhysical) { + logger.err('Cannot $verb physical device $label'); + return 1; + } + if (device.isRunning) { + logger.err( + '$label is running. Shut it down first: simutil shutdown ${device.id}', + ); + return 1; + } + + if (argResults!['yes'] != true && !logger.confirm(prompt(device))) { + logger.info('Cancelled'); + return 1; + } + + if (!await perform(device)) { + logger.err('Failed to $verb $label'); + return 1; + } + logger.success('$done $label'); + return 0; + } +} + +/// Erases a shut-down emulator or simulator's data. +class WipeCommand extends _DestructiveDeviceCommand { + /// Creates the `wipe` subcommand. + WipeCommand({super.logger, super.services}); + + @override + String get name => 'wipe'; + + @override + String get description => 'Wipe an emulator or simulator\'s data by id'; + + @override + String get verb => 'wipe'; + + @override + String get done => 'Wiped'; + + @override + String prompt(Device device) => + 'Erase all apps, settings and snapshots of ${device.name}? ' + 'This cannot be undone.'; + + @override + Future perform(Device device) => services.wipeDevice(device); +} + +/// Permanently deletes a shut-down emulator or simulator. +class DeleteCommand extends _DestructiveDeviceCommand { + /// Creates the `delete` subcommand. + DeleteCommand({super.logger, super.services}); + + @override + String get name => 'delete'; + + @override + String get description => 'Delete an emulator or simulator by id'; + + @override + String get verb => 'delete'; + + @override + String get done => 'Deleted'; + + @override + String prompt(Device device) => + 'Delete ${device.name} and all of its data? This cannot be undone.'; + + @override + Future perform(Device device) => services.deleteDevice(device); +} diff --git a/packages/simutil/lib/src/cli/simutil_command_runner.dart b/packages/simutil/lib/src/cli/simutil_command_runner.dart index 06091d7..af513b8 100644 --- a/packages/simutil/lib/src/cli/simutil_command_runner.dart +++ b/packages/simutil/lib/src/cli/simutil_command_runner.dart @@ -2,6 +2,7 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:simutil/src/cli/cli_catalog.dart'; +import 'package:simutil/src/cli/commands/destructive_device_commands.dart'; import 'package:simutil/src/cli/commands/schema_command.dart'; import 'package:simutil/src/cli/commands/launch_command.dart'; import 'package:simutil/src/cli/commands/list_command.dart'; @@ -38,6 +39,8 @@ class SimutilCommandRunner extends CommandRunner { addCommand(ListCommand(logger: _logger)); addCommand(LaunchCommand(logger: _logger)); addCommand(ShutdownCommand(logger: _logger)); + addCommand(WipeCommand(logger: _logger)); + addCommand(DeleteCommand(logger: _logger)); addCommand(PluginCommand(logger: _logger)); addCommand(SchemaCommand(logger: _logger, version: _version)); addCommand( diff --git a/packages/simutil/lib/src/tui/app/simutil_tui_app.dart b/packages/simutil/lib/src/tui/app/simutil_tui_app.dart index 131cf35..03d3914 100644 --- a/packages/simutil/lib/src/tui/app/simutil_tui_app.dart +++ b/packages/simutil/lib/src/tui/app/simutil_tui_app.dart @@ -314,6 +314,8 @@ class _SimutilTuiAppState extends State { return _joinStatusHints([ 'Launch: or ', if (device.isRunning) 'Shutdown: t', + if (!device.isRunning) 'Wipe data: d', + if (!device.isRunning) 'Delete: D', 'Plugins: p', 'Edit config: e', 'ADB Tools: n', @@ -353,6 +355,8 @@ class _SimutilTuiAppState extends State { 'Launch with option: ', if (device.isRunning) 'Shutdown: t', if (device.isRunning) 'Logcat: l', + if (!device.isRunning) 'Wipe data: d', + if (!device.isRunning) 'Delete: D', 'Plugins: p', 'Edit config: e', 'ADB Tools: n', @@ -680,6 +684,74 @@ class _SimutilTuiAppState extends State { } } + Future _onDeviceWipeRequested(Device device) => _confirmAndRun( + device, + title: 'Wipe Data', + message: + 'Erase all apps, settings and snapshots of ${device.name}?\n\n' + ' This cannot be undone.', + verb: 'wipe', + progress: 'Wiping', + done: 'wiped', + action: (service) => service.wipeData(deviceId: device.id), + ); + + Future _onDeviceDeleteRequested(Device device) => _confirmAndRun( + device, + title: 'Delete Device', + message: + 'Delete ${device.name} and all of its data?\n\n' + ' This cannot be undone.', + verb: 'delete', + progress: 'Deleting', + done: 'deleted', + action: (service) => service.deleteSimulator(deviceId: device.id), + ); + + /// Asks before a destructive [action] on a shut-down emulator / simulator. + Future _confirmAndRun( + Device device, { + required String title, + required String message, + required String verb, + required String progress, + required String done, + required Future Function(DeviceService service) action, + }) async { + if (device.type.isPhysical) return; + if (device.isRunning) { + setState( + () => + _statusMessage = 'Shut down ${device.name} before you $verb it (t)', + ); + return; + } + + final confirmed = await showConfirmDialog( + context: context, + title: title, + message: message, + ); + if (!mounted) return; + if (!confirmed) { + setState(() => _statusMessage = _buildIdleStatusMessage()); + return; + } + + setState(() => _statusMessage = '$progress ${device.name}…'); + final service = device.os == DeviceOs.android + ? _di.adbService + : _di.simctlService; + final ok = await action(service); + if (!mounted) return; + setState( + () => _statusMessage = ok + ? '${device.name} $done!' + : 'Failed to $verb ${device.name}', + ); + await _refreshDevices(silent: true); + } + Future _onDeviceLogcatRequested(Device device) async { await showLogcatDialog( context: context, @@ -847,6 +919,8 @@ class _SimutilTuiAppState extends State { isLoading: _loadingAndroidEmulators, selectedIndex: _androidEmulatorSelectedIndex, onDeviceShutdownRequested: _onDeviceShutdownRequested, + onDeviceWipeRequested: _onDeviceWipeRequested, + onDeviceDeleteRequested: _onDeviceDeleteRequested, emptyMessage: 'No Android emulators found', onSelectionChanged: (i) => setState(() { _androidEmulatorSelectedIndex = i; @@ -895,6 +969,8 @@ class _SimutilTuiAppState extends State { onDeviceLaunchRequested: _onDeviceDefaultLaunch, onDeviceShowOptions: _onDeviceShowOptions, onDeviceShutdownRequested: _onDeviceShutdownRequested, + onDeviceWipeRequested: _onDeviceWipeRequested, + onDeviceDeleteRequested: _onDeviceDeleteRequested, ), ); } diff --git a/packages/simutil/lib/src/tui/components/device_list_component.dart b/packages/simutil/lib/src/tui/components/device_list_component.dart index d219ad5..254332a 100644 --- a/packages/simutil/lib/src/tui/components/device_list_component.dart +++ b/packages/simutil/lib/src/tui/components/device_list_component.dart @@ -17,6 +17,8 @@ class DeviceListComponent extends StatefulComponent { this.onDeviceShowOptions, this.onDeviceShutdownRequested, this.onDeviceLogcatRequested, + this.onDeviceWipeRequested, + this.onDeviceDeleteRequested, this.isLoading = false, this.loadingMessage = 'Loading devices...', this.emptyMessage = 'No devices found', @@ -49,6 +51,12 @@ class DeviceListComponent extends StatefulComponent { /// Called on `l` to open logcat for a running device. final void Function(Device)? onDeviceLogcatRequested; + /// Called on `d` to wipe the selected device's data. + final void Function(Device)? onDeviceWipeRequested; + + /// Called on `D` (shift+d) to delete the selected device. + final void Function(Device)? onDeviceDeleteRequested; + /// When true, shows [loadingMessage] instead of the list. final bool isLoading; @@ -135,6 +143,16 @@ class _DeviceListComponentState extends State { case LogicalKey.keyL: _handleLogcat(); return true; + case LogicalKey.keyD when !event.isControlPressed && !event.isAltPressed: + final callback = event.isShiftPressed + ? component.onDeviceDeleteRequested + : component.onDeviceWipeRequested; + if (callback == null || + component.selectedIndex >= component.devices.length) { + return false; + } + callback(component.devices[component.selectedIndex]); + return true; default: return false; } diff --git a/packages/simutil/test/cli/destructive_device_commands_test.dart b/packages/simutil/test/cli/destructive_device_commands_test.dart new file mode 100644 index 0000000..3ed33f2 --- /dev/null +++ b/packages/simutil/test/cli/destructive_device_commands_test.dart @@ -0,0 +1,66 @@ +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:simutil/src/cli/cli_device_services.dart'; +import 'package:simutil/src/cli/commands/destructive_device_commands.dart'; +import 'package:simutil_core/simutil_core.dart'; +import 'package:simutil_core/testing.dart'; +import 'package:test/test.dart'; + +/// Answers every confirm with [answer] and counts prompts. +class _Logger extends Logger { + _Logger({this.answer = false}) : super(level: Level.quiet); + + final bool answer; + int prompts = 0; + + @override + bool confirm(String? message, {bool defaultValue = false}) { + prompts++; + return answer; + } +} + +void main() { + final stopped = testAndroidEmulator(state: DeviceState.shutdown); + final running = testAndroidEmulator(id: 'Running_AVD'); + + late FakeDeviceService android; + + setUp(() { + android = FakeDeviceService(simulators: [stopped, running]); + }); + + Future run(_Logger logger, List args) { + final services = CliDeviceServices(services: {DeviceOs.android: android}); + final runner = CommandRunner('simutil', '') + ..addCommand(WipeCommand(logger: logger, services: services)) + ..addCommand(DeleteCommand(logger: logger, services: services)); + return runner.run(args); + } + + test('wipe asks and does nothing when declined', () async { + final logger = _Logger(); + expect(await run(logger, ['wipe', stopped.id]), 1); + expect(logger.prompts, 1); + expect(android.wiped, []); + }); + + test('wipe runs after yes', () async { + expect(await run(_Logger(answer: true), ['wipe', stopped.id]), 0); + expect(android.wiped, [stopped.id]); + }); + + test('delete --yes skips the prompt', () async { + final logger = _Logger(); + expect(await run(logger, ['delete', stopped.id, '--yes']), 0); + expect(logger.prompts, 0); + expect(android.deleted, [stopped.id]); + }); + + test('refuses a running device without prompting', () async { + final logger = _Logger(answer: true); + expect(await run(logger, ['delete', running.id, '-y']), 1); + expect(logger.prompts, 0); + expect(android.deleted, []); + }); +} diff --git a/packages/simutil/test/cli/simutil_command_runner_test.dart b/packages/simutil/test/cli/simutil_command_runner_test.dart index 73e84a7..d5a2682 100644 --- a/packages/simutil/test/cli/simutil_command_runner_test.dart +++ b/packages/simutil/test/cli/simutil_command_runner_test.dart @@ -14,6 +14,8 @@ void main() { 'list', 'launch', 'shutdown', + 'wipe', + 'delete', 'plugin', 'schema', 'upgrade', diff --git a/packages/simutil/test/tui/components/device_list_component_test.dart b/packages/simutil/test/tui/components/device_list_component_test.dart new file mode 100644 index 0000000..5423b98 --- /dev/null +++ b/packages/simutil/test/tui/components/device_list_component_test.dart @@ -0,0 +1,38 @@ +import 'package:nocterm/nocterm.dart'; +import 'package:simutil/src/tui/components/device_list_component.dart'; +import 'package:simutil_core/simutil_core.dart'; +import 'package:simutil_core/testing.dart'; +import 'package:test/test.dart' hide isEmpty, isNotEmpty; + +void main() { + test('d wipes and shift+d deletes the selected device', () async { + final wiped = []; + final deleted = []; + final device = testAndroidEmulator(); + + await testNocterm('device list d / D', (tester) async { + await tester.pumpComponent( + DeviceListComponent( + devices: [device], + focused: true, + onDeviceWipeRequested: wiped.add, + onDeviceDeleteRequested: deleted.add, + ), + ); + + await tester.sendKey(LogicalKey.keyD); + expect(wiped, [device]); + expect(deleted, []); + + await tester.sendKeyEvent( + const KeyboardEvent( + logicalKey: LogicalKey.keyD, + character: 'D', + modifiers: ModifierKeys(shift: true), + ), + ); + expect(deleted, [device]); + expect(wiped, hasLength(1)); + }); + }); +} From a8ed36b89ec79dc1e25477dbd69196439462f535 Mon Sep 17 00:00:00 2001 From: dungngminh <63831488+dungngminh@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:12:59 +0700 Subject: [PATCH 2/2] chore: release simutil 1.2.0 --- packages/simutil/CHANGELOG.md | 8 ++++---- packages/simutil/lib/src/version.dart | 2 +- packages/simutil/pubspec.yaml | 2 +- packages/simutil_shared/lib/src/changelog_entries.dart | 10 ++++++++++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/simutil/CHANGELOG.md b/packages/simutil/CHANGELOG.md index 97884f8..c8385bf 100644 --- a/packages/simutil/CHANGELOG.md +++ b/packages/simutil/CHANGELOG.md @@ -7,12 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-10-17 + ### Added -- Press `d` to wipe a shut-down emulator or simulator's data, or `D` to delete - it, after a confirmation prompt. -- `simutil wipe ` and `simutil delete ` do the same from the command - line; they ask y/N first, `--yes` skips the question. +- Press `d` to wipe a shut-down emulator or simulator, or `D` to delete it (asks first). +- `simutil wipe ` and `simutil delete ` do the same; `--yes` skips the question. ### Fixed diff --git a/packages/simutil/lib/src/version.dart b/packages/simutil/lib/src/version.dart index d3085d5..c42772a 100644 --- a/packages/simutil/lib/src/version.dart +++ b/packages/simutil/lib/src/version.dart @@ -1,4 +1,4 @@ // Generated by tool/generate_version.dart from pubspec.yaml. Do not edit by hand. /// SimUtil app version (`packages/simutil/pubspec.yaml`). -const packageVersion = '1.1.0'; +const packageVersion = '1.2.0'; diff --git a/packages/simutil/pubspec.yaml b/packages/simutil/pubspec.yaml index 2d3a134..5b47c9d 100644 --- a/packages/simutil/pubspec.yaml +++ b/packages/simutil/pubspec.yaml @@ -1,6 +1,6 @@ name: simutil description: TUI and CLI for launching Android emulators and iOS simulators, with ADB tools, Logcat viewer, and YAML plugins. -version: 1.1.0 +version: 1.2.0 homepage: https://github.com/dungngminh/simutil repository: https://github.com/dungngminh/simutil/tree/main/packages/simutil issue_tracker: https://github.com/dungngminh/simutil/issues diff --git a/packages/simutil_shared/lib/src/changelog_entries.dart b/packages/simutil_shared/lib/src/changelog_entries.dart index 21ad2e8..c42eafb 100644 --- a/packages/simutil_shared/lib/src/changelog_entries.dart +++ b/packages/simutil_shared/lib/src/changelog_entries.dart @@ -13,6 +13,16 @@ class ChangelogEntry { } const List changelogEntries = [ + ChangelogEntry( + version: '1.2.0', + date: '2026-10-17', + items: [ + 'Press `d` to wipe a shut-down emulator or simulator, or `D` to delete it (asks first).', + '`simutil wipe ` and `simutil delete ` do the same; `--yes` skips the question.', + 'Connected iPhones and iPads show up again in the device list.', + 'The update check no longer mistakes a desktop app release for a new CLI version.', + ], + ), ChangelogEntry( version: '1.1.0', date: '2026-10-14',