Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ simutil launch <device-id> # boot (alias: start)
simutil launch <id> -c --no-audio # cold boot, no audio (Android)
simutil launch <id> --headless # no emulator window / Simulator app
simutil shutdown <device-id> # alias: stop
simutil wipe <device-id> # erase data of a shut-down device (asks y/N)
simutil delete <device-id> -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
Expand Down
7 changes: 7 additions & 0 deletions packages/simutil/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ 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, or `D` to delete it (asks first).
- `simutil wipe <id>` and `simutil delete <id>` do the same; `--yes` skips the question.

### Fixed

- Connected iPhones and iPads show up again in the device list.
Expand Down
28 changes: 28 additions & 0 deletions packages/simutil/lib/src/cli/cli_catalog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@ List<CliCommandSpec> simutilCliCatalog({required String version}) => [
flags: cliPlatformFlags,
examples: ['simutil shutdown emulator-5554', 'simutil stop <udid> -i'],
),
CliCommandSpec(
name: 'wipe',
summary: 'Erase a shut-down emulator or simulator\'s data (asks y/N)',
usage: 'simutil wipe <device-id> [options]',
arguments: ['device-id'],
flags: [...cliPlatformFlags, _yesFlag],
examples: [
'simutil wipe Pixel_7_Pro_big_Android_15',
'simutil wipe <udid> -y',
],
),
CliCommandSpec(
name: 'delete',
summary: 'Delete a shut-down emulator or simulator (asks y/N)',
usage: 'simutil delete <device-id> [options]',
arguments: ['device-id'],
flags: [...cliPlatformFlags, _yesFlag],
examples: [
'simutil delete Pixel_7_Pro_big_Android_15',
'simutil delete <udid> -i -y',
],
),
_pluginCommand(),
CliCommandSpec(
name: 'schema',
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions packages/simutil/lib/src/cli/cli_device_services.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ class CliDeviceServices {
Future<bool> shutdownDevice(Device device) =>
_serviceFor(device).shutdownSimulator(deviceId: device.id);

/// Erases a shut-down simulator/emulator's user data.
Future<bool> wipeDevice(Device device) =>
_serviceFor(device).wipeData(deviceId: device.id);

/// Permanently deletes a shut-down simulator/emulator.
Future<bool> deleteDevice(Device device) =>
_serviceFor(device).deleteSimulator(deviceId: device.id);

DeviceService _serviceFor(Device device) =>
_services[device.os] ??
cliUsage('No service registered for ${device.os.name}');
Expand Down
129 changes: 129 additions & 0 deletions packages/simutil/lib/src/cli/commands/destructive_device_commands.dart
Original file line number Diff line number Diff line change
@@ -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<bool> 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<int> 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<bool> 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<bool> perform(Device device) => services.deleteDevice(device);
}
3 changes: 3 additions & 0 deletions packages/simutil/lib/src/cli/simutil_command_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -38,6 +39,8 @@ class SimutilCommandRunner extends CommandRunner<int> {
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(
Expand Down
76 changes: 76 additions & 0 deletions packages/simutil/lib/src/tui/app/simutil_tui_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ class _SimutilTuiAppState extends State<SimutilTuiApp> {
return _joinStatusHints([
'Launch: <space> or <enter>',
if (device.isRunning) 'Shutdown: t',
if (!device.isRunning) 'Wipe data: d',
if (!device.isRunning) 'Delete: D',
'Plugins: p',
'Edit config: e',
'ADB Tools: n',
Expand Down Expand Up @@ -353,6 +355,8 @@ class _SimutilTuiAppState extends State<SimutilTuiApp> {
'Launch with option: <enter>',
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',
Expand Down Expand Up @@ -680,6 +684,74 @@ class _SimutilTuiAppState extends State<SimutilTuiApp> {
}
}

Future<void> _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<void> _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<void> _confirmAndRun(
Device device, {
required String title,
required String message,
required String verb,
required String progress,
required String done,
required Future<bool> 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<void> _onDeviceLogcatRequested(Device device) async {
await showLogcatDialog(
context: context,
Expand Down Expand Up @@ -847,6 +919,8 @@ class _SimutilTuiAppState extends State<SimutilTuiApp> {
isLoading: _loadingAndroidEmulators,
selectedIndex: _androidEmulatorSelectedIndex,
onDeviceShutdownRequested: _onDeviceShutdownRequested,
onDeviceWipeRequested: _onDeviceWipeRequested,
onDeviceDeleteRequested: _onDeviceDeleteRequested,
emptyMessage: 'No Android emulators found',
onSelectionChanged: (i) => setState(() {
_androidEmulatorSelectedIndex = i;
Expand Down Expand Up @@ -895,6 +969,8 @@ class _SimutilTuiAppState extends State<SimutilTuiApp> {
onDeviceLaunchRequested: _onDeviceDefaultLaunch,
onDeviceShowOptions: _onDeviceShowOptions,
onDeviceShutdownRequested: _onDeviceShutdownRequested,
onDeviceWipeRequested: _onDeviceWipeRequested,
onDeviceDeleteRequested: _onDeviceDeleteRequested,
),
);
}
Expand Down
18 changes: 18 additions & 0 deletions packages/simutil/lib/src/tui/components/device_list_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -135,6 +143,16 @@ class _DeviceListComponentState extends State<DeviceListComponent> {
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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/simutil/lib/src/version.dart
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 1 addition & 1 deletion packages/simutil/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading