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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2026-03-23

### Added

- Add `shutdown` action for Android emulators and iOS simulators.

### Changed

- Change default keymap for `launch` and `launch with option` actions for Android emulators.

## [0.2.1] - 2026-03-21

### Fixed
Expand Down
25 changes: 19 additions & 6 deletions lib/components/device_list_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ class DeviceListComponent extends StatefulComponent {
this.selectedIndex = 0,
this.scrollBufferItems = 2,
this.onSelectionChanged,
this.onDeviceLaunch,
this.onDeviceLaunchRequested,
this.onDeviceShowOptions,
this.onDeviceShutdownRequested,
this.isLoading = false,
this.loadingMessage = 'Loading devices...',
this.emptyMessage = 'No devices found',
Expand All @@ -23,9 +24,10 @@ class DeviceListComponent extends StatefulComponent {
final bool focused;
final int selectedIndex;
final int scrollBufferItems;
final ValueChanged<int>? onSelectionChanged;
final ValueChanged<Device>? onDeviceLaunch;
final ValueChanged<Device>? onDeviceShowOptions;
final void Function(int)? onSelectionChanged;
final void Function(Device)? onDeviceLaunchRequested;
final void Function(Device)? onDeviceShowOptions;
final void Function(Device)? onDeviceShutdownRequested;
final bool isLoading;
final String loadingMessage;
final String emptyMessage;
Expand Down Expand Up @@ -101,6 +103,9 @@ class _DeviceListComponentState extends State<DeviceListComponent> {
case LogicalKey.space:
_handleSpace();
return true;
case LogicalKey.keyT:
_handleShutdown();
return true;
default:
return false;
}
Expand Down Expand Up @@ -134,15 +139,23 @@ class _DeviceListComponentState extends State<DeviceListComponent> {

void _handleEnter() {
if (component.selectedIndex < component.devices.length) {
component.onDeviceLaunch?.call(
component.onDeviceShowOptions?.call(
component.devices[component.selectedIndex],
);
}
}

void _handleSpace() {
if (component.selectedIndex < component.devices.length) {
component.onDeviceShowOptions?.call(
component.onDeviceLaunchRequested?.call(
component.devices[component.selectedIndex],
);
}
}

void _handleShutdown() {
if (component.selectedIndex < component.devices.length) {
component.onDeviceShutdownRequested?.call(
component.devices[component.selectedIndex],
);
}
Expand Down
16 changes: 15 additions & 1 deletion lib/services/android_device_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import 'package:simutil/services/command_exec.dart';
import 'package:simutil/services/device_service.dart';

class AndroidDeviceService implements DeviceService {
AndroidDeviceService(this._exec);
const AndroidDeviceService(this._exec);

final CommandExec _exec;

String getAndroidHome() {
Expand Down Expand Up @@ -279,6 +280,19 @@ class AndroidDeviceService implements DeviceService {
return [];
}
}

@override
Future<bool> shutdownSimulator({required String deviceId}) async {
try {
final result = await _exec.run(
adbPath,
arguments: ['-s', deviceId, 'emu', 'kill'],
);
return result.success;
} catch (e) {
return false;
}
}
}

class AdbConnectResult {
Expand Down
4 changes: 3 additions & 1 deletion lib/services/command_exec.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ abstract class CommandExec {
}

class CommandExecImpl implements CommandExec {

@override
Future<CommandResult> run(
String command, {
Expand All @@ -44,7 +45,8 @@ class CommandExecImpl implements CommandExec {
}

class IsolateCommandExec implements CommandExec {
IsolateCommandExec(this._runner);
const IsolateCommandExec(this._runner);

final IsolateRunner _runner;

@override
Expand Down
2 changes: 2 additions & 0 deletions lib/services/device_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ abstract class DeviceService {
required String deviceId,
List<String> additionalArgs = const [],
});

Future<bool> shutdownSimulator({required String deviceId});
}
8 changes: 5 additions & 3 deletions lib/services/ios_device_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import 'package:simutil/services/command_exec.dart';
import 'package:simutil/services/device_service.dart';

class IOSDeviceService implements DeviceService {
IOSDeviceService(this._exec);
const IOSDeviceService(this._exec);

final CommandExec _exec;

@override
Expand Down Expand Up @@ -102,11 +103,12 @@ class IOSDeviceService implements DeviceService {
);
}

Future<bool> shutdownSimulator(String udid) async {
@override
Future<bool> shutdownSimulator({required String deviceId}) async {
try {
final result = await _exec.run(
'xcrun',
arguments: ['simctl', 'shutdown', udid],
arguments: ['simctl', 'shutdown', deviceId],
);
return result.success;
} catch (_) {
Expand Down
56 changes: 43 additions & 13 deletions lib/simutil_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,10 @@ class _SimutilAppState extends State<SimutilApp> {
}

String _buildIdleStatusMessageForIosSimulators() {
final device = _iosSimulators[_iosSimulatorSelectedIndex];
final parts = <String>[
'Launch: <enter> or <space>',
'Launch: <space> or <enter>',
if (device.isRunning) 'Shutdown: t',
'ADB Tools: n',
'Refresh: r',
'Switch: <tab>',
Expand All @@ -224,9 +226,11 @@ class _SimutilAppState extends State<SimutilApp> {
}

String _buildIdleStatusMessageForAndroidEmulators() {
final device = _androidEmulators[_androidEmulatorSelectedIndex];
final parts = <String>[
'Launch: <enter>',
'Launch with option: <space>',
'Launch: <space>',
'Launch with option: <enter>',
if (device.isRunning) 'Shutdown: t',
'ADB Tools: n',
'Refresh: r',
'Switch: <tab>',
Expand Down Expand Up @@ -455,6 +459,25 @@ class _SimutilAppState extends State<SimutilApp> {
}
}

Future<void> _onDeviceShutdownRequested(Device device) async {
try {
if (device.type.isPhysical || !device.isRunning) return;
setState(() => _statusMessage = 'Shutting down ${device.name}…');
if (device.os == DeviceOs.android) {
await _di.adbService.shutdownSimulator(deviceId: device.id);
} else {
await _di.simctlService.shutdownSimulator(deviceId: device.id);
}
setState(() => _statusMessage = '${device.name} shut down!');
Future.delayed(
kReloadAfterActionInterval,
() => _refreshDevices(silent: true),
);
} catch (e) {
setState(() => _statusMessage = 'Failed to shut down ${device.name}: $e');
}
}

@override
Component build(BuildContext context) {
return TuiTheme(data: _themeData, child: _buildShell(context));
Expand Down Expand Up @@ -508,9 +531,10 @@ class _SimutilAppState extends State<SimutilApp> {
isLoading: _loadingAndroidDevices,
selectedIndex: _androidDeviceSelectedIndex,
emptyMessage: 'No Android devices found',
onSelectionChanged: (i) =>
setState(() => _androidDeviceSelectedIndex = i),
onDeviceLaunch: null,
onSelectionChanged: (i) => setState(() {
_androidDeviceSelectedIndex = i;
}),
onDeviceLaunchRequested: null,
onDeviceShowOptions: null,
),
);
Expand All @@ -528,10 +552,13 @@ class _SimutilAppState extends State<SimutilApp> {
focused: focused,
isLoading: _loadingAndroidEmulators,
selectedIndex: _androidEmulatorSelectedIndex,
onDeviceShutdownRequested: _onDeviceShutdownRequested,
emptyMessage: 'No Android emulators found',
onSelectionChanged: (i) =>
setState(() => _androidEmulatorSelectedIndex = i),
onDeviceLaunch: _onDeviceDefaultLaunch,
onSelectionChanged: (i) => setState(() {
_androidEmulatorSelectedIndex = i;
_statusMessage = _buildIdleStatusMessage();
}),
onDeviceLaunchRequested: _onDeviceDefaultLaunch,
onDeviceShowOptions: _onDeviceShowOptions,
),
);
Expand Down Expand Up @@ -566,10 +593,13 @@ class _SimutilAppState extends State<SimutilApp> {
selectedIndex: _iosSimulatorSelectedIndex,
loadingMessage: 'Loading devices...\nFirst load may take a while',
emptyMessage: 'No iOS simulators found',
onSelectionChanged: (i) =>
setState(() => _iosSimulatorSelectedIndex = i),
onDeviceLaunch: _onDeviceDefaultLaunch,
onSelectionChanged: (i) => setState(() {
_iosSimulatorSelectedIndex = i;
_statusMessage = _buildIdleStatusMessage();
}),
onDeviceLaunchRequested: _onDeviceDefaultLaunch,
onDeviceShowOptions: _onDeviceShowOptions,
onDeviceShutdownRequested: _onDeviceShutdownRequested,
),
);
}
Expand All @@ -588,7 +618,7 @@ class _SimutilAppState extends State<SimutilApp> {
selectedIndex: _iosDeviceSelectedInded,
emptyMessage: 'No iOS devices found',
onSelectionChanged: (i) => setState(() => _iosDeviceSelectedInded = i),
onDeviceLaunch: _onDeviceDefaultLaunch,
onDeviceLaunchRequested: _onDeviceDefaultLaunch,
onDeviceShowOptions: _onDeviceShowOptions,
),
);
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/version.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: simutil
description: TUI application for launching Android Simulators / iOS Simulators and more ...
version: 0.2.1
version: 0.3.0
repository: https://github.com/dungngminh/simutil

environment:
Expand Down
Loading