diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a68a29..15a5ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.6.0 (2026-05-02) + +### 🚀 New Features +- Add an option to Start iOS Simulator in Device Picker UI (many thanks to [@chungxon](https://github.com/chungxon) for your contribution). + ## 1.5.0 (2026-04-22) _This release requires Lumide version >= 0.2.0._ diff --git a/lib/src/services/device_service.dart b/lib/src/services/device_service.dart index 4710b07..1fd4878 100644 --- a/lib/src/services/device_service.dart +++ b/lib/src/services/device_service.dart @@ -21,6 +21,8 @@ class DeviceService { DeviceService(this.context, this.statusBar, this.daemonService); + bool get _isMacOS => io.Platform.isMacOS; + Future init() async { _deviceAddedSub = daemonService.onDeviceAdded.listen((device) { if (!_devices.any((d) => d['id'] == device['id'])) { @@ -53,11 +55,16 @@ class DeviceService { await _updateToolbar(); try { - final devices = await daemonService.getDevices(); + final devices = + List>.from(await daemonService.getDevices()) + ..sort((a, b) => _deviceSortRank(a).compareTo(_deviceSortRank(b))); _devices = List>.from(devices); if (_devices.isNotEmpty) { - if (_selectedDeviceId == null || + if (!_isInitialized) { + // During startup, prefer the best-ranked device from the refreshed list. + _selectedDeviceId = _devices.first['id'] as String?; + } else if (_selectedDeviceId == null || !_devices.any((d) => d['id'] == _selectedDeviceId)) { _selectedDeviceId = _devices.first['id']; } @@ -74,8 +81,13 @@ class DeviceService { } Future selectDevice([Map? position]) async { + final devices = List>.from(_devices) + ..sort((a, b) => _deviceSortRank(a).compareTo(_deviceSortRank(b))); + + final hasIosSimulatorDevice = devices.any(_isIosSimulatorDevice); + // Show cached devices immediately + Refresh option - final items = _devices.map((d) { + final items = devices.map((d) { final icon = _getDeviceIcon(d); return QuickPickItem( @@ -90,6 +102,15 @@ class DeviceService { // Add divider/refresh option items.add(const QuickPickItem(label: '', isSeparator: true)); + if (_isMacOS && !hasIosSimulatorDevice) { + items.add(const QuickPickItem( + label: 'Start iOS Simulator', + detail: 'Launch Apple Simulator app', + payload: 'start-ios-simulator', + icon: iconPlay, + )); + } + items.add(const QuickPickItem( label: 'Refresh Devices...', detail: 'Scan for connected devices', @@ -109,6 +130,8 @@ class DeviceService { await context.window.showMessage('Scanning for connected devices'); await refreshDevices(); await selectDevice(position); // Re-open picker + } else if (payload == 'start-ios-simulator') { + await _startIosSimulator(); } else { _selectedDeviceId = payload; await _updateToolbar(); @@ -116,6 +139,144 @@ class DeviceService { } } + Future _startIosSimulator() async { + if (!_isMacOS) { + return; + } + + await context.window.showMessage('Starting iOS Simulator...'); + + try { + final result = await context.shell.run( + 'open', + ['-a', 'Simulator'], + ); + if (result.exitCode != 0) { + final stderr = result.stderr.toString().trim(); + await context.window.showMessage( + stderr.isNotEmpty + ? 'Failed to start iOS Simulator: $stderr' + : 'Failed to start iOS Simulator.', + type: MessageType.error, + ); + return; + } + final selected = await _waitAndSelectIosSimulator(); + if (!selected) { + await context.window.showMessage( + 'Simulator started, but no iOS simulator device was detected yet. Try Refresh Devices.', + type: MessageType.warning, + ); + } + } catch (e) { + await context.window.showMessage( + 'Failed to start iOS Simulator: $e', + type: MessageType.error, + ); + } + } + + Future _waitAndSelectIosSimulator() async { + await refreshDevices(); + + final existing = _findFirstIosSimulatorDevice(_devices); + if (_trySelectDevice(existing)) { + await _updateToolbar(); + return true; + } + + final completer = Completer?>(); + late final StreamSubscription> addedSub; + addedSub = daemonService.onDeviceAdded.listen((device) { + if (!completer.isCompleted && _isIosSimulatorDevice(device)) { + completer.complete(device); + } + }); + + try { + final addedDevice = await completer.future.timeout( + const Duration(seconds: 20), + onTimeout: () => null, + ); + + await refreshDevices(); + + if (_trySelectDevice(addedDevice)) { + await _updateToolbar(); + return true; + } + + final detected = _findFirstIosSimulatorDevice(_devices); + if (_trySelectDevice(detected)) { + await _updateToolbar(); + return true; + } + } finally { + await addedSub.cancel(); + } + + return false; + } + + Map? _findFirstIosSimulatorDevice( + List> devices) { + for (final device in devices) { + if (_isIosSimulatorDevice(device)) { + return device; + } + } + return null; + } + + bool _trySelectDevice(Map? device) { + if (device == null) return false; + final id = device['id']; + if (id is! String || id.isEmpty) return false; + _selectedDeviceId = id; + return true; + } + + int _deviceSortRank(Map device) { + if (_isMacOS && _isIosSimulatorDevice(device)) { + return 0; + } + if (_isMacOS && _isMacOsDesktopDevice(device)) { + return 1; + } + return 2; + } + + bool _isIosSimulatorDevice(Map device) { + final isEmulator = device['emulator'] == true; + final category = device['category']?.toString().toLowerCase() ?? ''; + final platformType = device['platformType']?.toString().toLowerCase() ?? ''; + final platform = device['platform']?.toString().toLowerCase() ?? ''; + final targetPlatform = + device['targetPlatform']?.toString().toLowerCase() ?? ''; + + final iosLikePlatform = + platform == 'ios' || targetPlatform.startsWith('ios'); + + return isEmulator && + (category == 'mobile' || platformType == 'mobile') && + iosLikePlatform; + } + + bool _isMacOsDesktopDevice(Map device) { + final category = device['category']?.toString().toLowerCase() ?? ''; + final platformType = device['platformType']?.toString().toLowerCase() ?? ''; + final platform = device['platform']?.toString().toLowerCase() ?? ''; + final targetPlatform = + device['targetPlatform']?.toString().toLowerCase() ?? ''; + + return category == 'desktop' && + (platformType == 'desktop' || + platform == 'darwin' || + platform == 'macos' || + targetPlatform.startsWith('darwin') || + targetPlatform.startsWith('macos')); + } + Future _updateToolbar() async { if (_isLoading) { await context.toolbar.registerItem( diff --git a/plugin.yaml b/plugin.yaml index 2c81551..9fd577a 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -13,8 +13,7 @@ permissions: - flutter - dart - fvm - - sh - - bash + - open configuration: - key: flutter.hotReloadOnSave