diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 3f69d0a8..ecedb4eb 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -737,8 +737,15 @@ class MeshCoreConnector extends ChangeNotifier { _scanResults.clear(); _setState(MeshCoreConnectionState.scanning); - // Ensure any previous scan is fully stopped - await FlutterBluePlus.stopScan(); + // Ensure any previous scan is fully stopped. Guard with isScanningNow to + // avoid triggering stale native callbacks when no scan is active. + if (FlutterBluePlus.isScanningNow) { + try { + await FlutterBluePlus.stopScan(); + } catch (e) { + debugPrint('[FBP] stopScan error in startScan (ignored): $e'); + } + } await _scanSubscription?.cancel(); // On iOS/macOS, wait for Bluetooth to be powered on before scanning @@ -787,7 +794,18 @@ class MeshCoreConnector extends ChangeNotifier { } Future stopScan() async { - await FlutterBluePlus.stopScan(); + // Only call FlutterBluePlus.stopScan() when a scan is actually running. + // Calling it when idle triggers a native BLE completion callback even + // though no scan was started. After a hot restart Dart has already freed + // those callback handles, so the callback crashes with + // "Callback invoked after it has been deleted". + if (FlutterBluePlus.isScanningNow) { + try { + await FlutterBluePlus.stopScan(); + } catch (e) { + debugPrint('[FBP] stopScan error (ignored): $e'); + } + } await _scanSubscription?.cancel(); _scanSubscription = null; diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index 69e95c48..03d94462 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; +import '../utils/platform_info.dart'; import '../utils/usb_port_labels.dart'; import 'contacts_screen.dart'; @@ -25,9 +26,16 @@ class _UsbScreenState extends State { String? _selectedPort; String? _connectedPortDisplayLabel; String? _errorText; + Timer? _hotPlugTimer; late final MeshCoreConnector _connector; late final VoidCallback _connectionListener; + /// Whether the current platform supports dynamic hot-plug polling. + /// On desktop (macOS, Windows, Linux) we poll continuously so the user + /// never needs to hit Refresh manually. + bool get _supportsHotPlug => + PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS; + @override void initState() { super.initState(); @@ -58,6 +66,7 @@ class _UsbScreenState extends State { } }; _connector.addListener(_connectionListener); + _startHotPlugTimer(); } @override @@ -72,6 +81,8 @@ class _UsbScreenState extends State { @override void dispose() { + _hotPlugTimer?.cancel(); + _hotPlugTimer = null; _connector.removeListener(_connectionListener); if (!_navigatedToContacts && _connector.activeTransport == MeshCoreTransportType.usb && @@ -124,101 +135,77 @@ class _UsbScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Flexible( - flex: 3, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.usb, - size: iconSize, - color: theme.colorScheme.primary, - ), - SizedBox(height: gap), - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - l10n.usbScreenTitle, - textAlign: TextAlign.center, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - ), - SizedBox(height: math.max(4.0, gap * 0.5)), - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - l10n.usbScreenSubtitle, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ), - SizedBox(height: gap), - FittedBox( - fit: BoxFit.scaleDown, - child: Chip( - label: Text( - _connectedPortDisplayLabel != null && - _connectedPortDisplayLabel!.isNotEmpty - ? _friendlyPortName( - _connectedPortDisplayLabel!, - ) - : _selectedPort == null - ? l10n.usbScreenStatus - : _friendlyPortName(_selectedPort!), - overflow: TextOverflow.ellipsis, - ), - backgroundColor: - theme.colorScheme.surfaceContainerHighest, - ), - ), - ], + // ── Compact header ────────────────────────────────────── + Row( + children: [ + Icon( + Icons.usb, + size: iconSize.clamp(24.0, 40.0), + color: theme.colorScheme.primary, ), - ), + SizedBox(width: gap), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.usbScreenTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + l10n.usbScreenSubtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], ), SizedBox(height: gap), + // ── Port list takes all remaining space ───────────────── Expanded(child: _buildPortList(context)), if (_errorText != null) ...[ - SizedBox(height: gap), - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - _errorText!, - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), + SizedBox(height: gap * 0.5), + Text( + _errorText!, + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, ), ), ], SizedBox(height: gap), + // ── Action buttons ────────────────────────────────────── if (isNarrow) Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - OutlinedButton.icon( - onPressed: _isLoadingPorts || _isConnecting - ? null - : () { - debugPrint( - 'UsbScreen: refresh ports pressed', - ); - _loadPorts(); - }, - icon: const Icon(Icons.refresh), - label: Text(l10n.repeater_refresh), - ), - SizedBox(height: gap), + if (!_supportsHotPlug) ...[ + OutlinedButton.icon( + onPressed: _isLoadingPorts || _isConnecting + ? null + : () { + debugPrint( + 'UsbScreen: refresh ports pressed', + ); + _loadPorts(); + }, + icon: const Icon(Icons.refresh), + label: Text(l10n.repeater_refresh), + ), + SizedBox(height: gap), + ], FilledButton.icon( onPressed: _canConnect ? () { @@ -247,21 +234,23 @@ class _UsbScreenState extends State { else Row( children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _isLoadingPorts || _isConnecting - ? null - : () { - debugPrint( - 'UsbScreen: refresh ports pressed', - ); - _loadPorts(); - }, - icon: const Icon(Icons.refresh), - label: Text(l10n.repeater_refresh), + if (!_supportsHotPlug) ...[ + Expanded( + child: OutlinedButton.icon( + onPressed: _isLoadingPorts || _isConnecting + ? null + : () { + debugPrint( + 'UsbScreen: refresh ports pressed', + ); + _loadPorts(); + }, + icon: const Icon(Icons.refresh), + label: Text(l10n.repeater_refresh), + ), ), - ), - SizedBox(width: gap), + SizedBox(width: gap), + ], Expanded( child: FilledButton.icon( onPressed: _canConnect @@ -289,17 +278,14 @@ class _UsbScreenState extends State { ), ], ), - SizedBox(height: math.max(4.0, gap * 0.75)), - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - l10n.usbScreenNote, - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), + SizedBox(height: math.max(4.0, gap * 0.5)), + Text( + l10n.usbScreenNote, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), ), ], @@ -317,6 +303,43 @@ class _UsbScreenState extends State { _selectedPort != null && _selectedPort!.isNotEmpty; + void _startHotPlugTimer() { + if (!_supportsHotPlug) return; + _hotPlugTimer?.cancel(); + _hotPlugTimer = Timer.periodic(const Duration(seconds: 2), (_) { + _pollHotPlug(); + }); + } + + Future _pollHotPlug() async { + // Don't interfere with an active connection attempt or initial load. + if (_isConnecting || _isLoadingPorts) return; + if (!mounted) return; + try { + final ports = await _connector.listUsbPorts(); + if (!mounted) return; + final added = ports.where((p) => !_ports.contains(p)).toList(); + final removed = _ports.where((p) => !ports.contains(p)).toList(); + if (added.isEmpty && removed.isEmpty) return; + setState(() { + _ports + ..clear() + ..addAll(ports); + if (_ports.isEmpty) { + _selectedPort = null; + } else if (added.isNotEmpty) { + // Auto-select the newly-connected device. + _selectedPort = added.first; + } else if (_selectedPort != null && !_ports.contains(_selectedPort)) { + // Previously-selected device was unplugged. + _selectedPort = _ports.isNotEmpty ? _ports.first : null; + } + }); + } catch (_) { + // Silent — hot-plug failures are non-critical. + } + } + Widget _buildPortList(BuildContext context) { final theme = Theme.of(context); final l10n = context.l10n; @@ -464,14 +487,32 @@ class _UsbScreenState extends State { try { await _connector.connectUsb(portName: rawPortName); - } catch (error) { + } catch (error, stackTrace) { + debugPrint( + 'UsbScreen: connect failed for $rawPortName: $error\n$stackTrace', + ); if (!mounted) return; setState(() { _isConnecting = false; - _errorText = error.toString(); + _errorText = _friendlyErrorMessage(error); }); + // Re-scan so stale or renamed port entries are cleared from the list. + unawaited(_loadPorts()); } } + /// Strips the Dart runtime prefix (e.g. "Bad state: ", "Exception: ") + /// from error messages before showing them in the UI. + String _friendlyErrorMessage(Object error) { + var msg = error.toString(); + // StateError surfaces as "Bad state: " + if (msg.startsWith('Bad state: ')) { + msg = msg.substring('Bad state: '.length); + } else if (msg.startsWith('Exception: ')) { + msg = msg.substring('Exception: '.length); + } + return msg; + } + String _friendlyPortName(String portLabel) => friendlyUsbPortName(portLabel); } diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart index d79205be..9f442a1a 100644 --- a/lib/services/usb_serial_service_native.dart +++ b/lib/services/usb_serial_service_native.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flserial/flserial.dart'; import 'package:flserial/flserial_exception.dart'; @@ -6,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'app_debug_log_service.dart'; +import '../utils/macos_usb_device_names.dart'; import '../utils/platform_info.dart'; import '../utils/usb_port_labels.dart'; import 'usb_serial_frame_codec.dart'; @@ -32,6 +34,14 @@ class UsbSerialService { FlSerial? _serial; AppDebugLogService? _debugLogService; + /// Holds the last-opened native serial port across hot-restart boundaries. + /// On hot restart the Dart isolate is torn down without running [disconnect], + /// leaving the native SerialThread alive. The next [connect] call reads this + /// field and force-closes the orphaned port before creating a new one, which + /// causes the old native thread to unblock its blocking read and exit + /// naturally—before any new Dart FFI callbacks are registered. + static FlSerial? _lastSerial; + UsbSerialStatus get status => _status; String? get activePortKey => _connectedPortKey; String? get activePortDisplayLabel => @@ -40,19 +50,24 @@ class UsbSerialService { bool get _useAndroidUsbHost => !kIsWeb && defaultTargetPlatform == TargetPlatform.android; bool get _useDesktopFlSerial => - PlatformInfo.isWindows || PlatformInfo.isLinux; + PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS; bool get _isSupportedPlatform => _useAndroidUsbHost || _useDesktopFlSerial; - FlSerial get _nativeSerial => _serial ??= FlSerial(); + // Always-fresh: do NOT use ??= here – a cached FlSerial retains stale + // native handle state (flh) from a prior failed open, causing subsequent + // open attempts to fail with "port not exist" even when the device is present. + FlSerial _freshSerial() => FlSerial(); bool get isConnected { if (!_isSupportedPlatform) { return false; } - if (_useAndroidUsbHost) { - return _status == UsbSerialStatus.connected; - } - return _status == UsbSerialStatus.connected && - _serial?.isOpen() == FlOpenStatus.open; + // Trust _status as the authoritative connection state. Polling + // _serial?.isOpen() via the native FL_CTRL_IS_PORT_OPEN query is + // unreliable during the brief USB re-enumeration window that many + // microcontrollers (e.g. NRF52) trigger in response to DTR assertion. + // Actual port drops are handled by the onDone / onError callbacks on the + // serial data stream subscription, which update _status correctly. + return _status == UsbSerialStatus.connected; } Future> listPorts() async { @@ -65,7 +80,34 @@ class UsbSerialService { ); return ports ?? []; } - return Future.value(FlSerial.listPorts()); + final rawPorts = FlSerial.listPorts(); + // On macOS, flserial's native device-name lookup is broken on macOS + // 10.15+ because the IOKit class name changed from IOUSBDevice to + // IOUSBHostDevice. We resolve names ourselves via ioreg and rewrite any + // "port - n/a" entries with the real product name. + if (Platform.isMacOS && rawPorts.isNotEmpty) { + return _annotateMacOsPorts(rawPorts); + } + return Future.value(rawPorts); + } + + /// Rewrites the flserial port list on macOS by substituting real USB device + /// names (obtained via [ioreg]) for the "n/a" placeholders that flserial + /// returns when it can't find the deprecated IOUSBDevice parent. + Future> _annotateMacOsPorts(List rawPorts) async { + final deviceNames = await queryMacOsUsbDeviceNames(); + if (deviceNames.isEmpty) return rawPorts; + return rawPorts.map((entry) { + // entry format from fl_ports: "port - description - hardware_id" + final port = normalizeUsbPortName(entry); // e.g. /dev/cu.usbmodem1101 + final knownName = deviceNames[port]; // e.g. "Nordic NRF52 DK" + if (knownName == null) return entry; // non-USB port, keep as-is + // Replace description field only; preserve hardware_id for device + // identity (used by normalizeUsbPortName). + final segments = entry.split(' - '); + final hardwareId = segments.length >= 3 ? segments.last : 'n/a'; + return '$port - $knownName - $hardwareId'; + }).toList(); } void setDebugLogService(AppDebugLogService? service) { @@ -85,7 +127,7 @@ class UsbSerialService { } _status = UsbSerialStatus.connecting; - final normalizedPortName = normalizeUsbPortName(portName); + var normalizedPortName = normalizeUsbPortName(portName); _frameDecoder.reset(); if (_useAndroidUsbHost) { @@ -100,41 +142,111 @@ class UsbSerialService { ); } on PlatformException catch (error) { _status = UsbSerialStatus.disconnected; - throw StateError(error.message ?? error.code); - } - } else { - final serial = _nativeSerial; - serial.init(); - - try { - final status = serial.openPort(normalizedPortName, baudRate); - if (status != FlOpenStatus.open) { - throw StateError( - 'Failed to open USB port $normalizedPortName ($status)', - ); - } - serial.setByteSize8(); - serial.setBitParityNone(); - serial.setStopBits1(); - serial.setFlowControlNone(); - serial.setRTS(false); - serial.setDTR(true); - _debugLogService?.info( - 'USB serial opened port=$normalizedPortName cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false', + final msg = error.message ?? error.code; + debugPrint('[USB Serial] Android connect failed: $msg'); + _debugLogService?.error( + 'Android connect failed: $msg', tag: 'USB Serial', ); - } on FlSerialException catch (error) { - _serial?.free(); - _serial = null; + throw StateError(msg); + } + } else { + // Force-close any native serial port left open by the previous Dart + // isolate (hot-restart case). The old SerialThread blocks on read(); once + // the port is closed here it unblocks and exits before we register any + // new Dart FFI callbacks, preventing the "callback invoked after deletion" + // crash. + final orphan = _lastSerial; + if (orphan != null) { + _lastSerial = null; + try { + if (orphan.isOpen() == FlOpenStatus.open) { + orphan.closePort(); + } + } catch (_) {} + try { + orphan.free(); + } catch (_) {} + // Give the native thread a moment to observe the port closure and exit. + await Future.delayed(const Duration(milliseconds: 100)); + } + + // On macOS, flserial lists both cu.* and tty.* device nodes. + // When a cu.* open fails with FL_ERROR_PORT_NOT_EXIST, try the tty.* + // variant as a fallback (and vice-versa) before giving up. + final candidates = _buildPortCandidates(normalizedPortName); + FlSerialException? lastError; + bool opened = false; + + for (final candidate in candidates) { + // Always create a fresh FlSerial instance — a cached instance retains + // a stale flh handle from prior failed opens, which causes the native + // fl_open() to mis-route the request and report port-not-exist even + // when the device node is physically present. + final serial = _freshSerial(); + serial.init(); + try { + final openStatus = serial.openPort(candidate, baudRate); + if (openStatus != FlOpenStatus.open) { + final msg = + 'Failed to open USB port $candidate (status: $openStatus)'; + debugPrint('[USB Serial] $msg'); + _debugLogService?.error(msg, tag: 'USB Serial'); + // Not a FlSerialException — treat as terminal failure + _status = UsbSerialStatus.disconnected; + throw StateError(msg); + } + serial.setByteSize8(); + serial.setBitParityNone(); + serial.setStopBits1(); + serial.setFlowControlNone(); + serial.setRTS(false); + serial.setDTR(true); + _serial = serial; + _lastSerial = serial; + // Update the normalized port name to whichever candidate succeeded. + normalizedPortName = candidate; + _debugLogService?.info( + 'USB serial opened port=$candidate cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false', + tag: 'USB Serial', + ); + opened = true; + break; + } on FlSerialException catch (error) { + // Do NOT call fl_free() here — it destroys global native library + // state and makes subsequent fl_init() calls unreliable. The native + // fl_open() already called fl_close() on failure internally. + debugPrint( + '[USB Serial] Failed to open $candidate: ${error.msg} (code ${error.error})', + ); + _debugLogService?.warn( + 'Failed to open $candidate: ${error.msg} (code ${error.error})', + tag: 'USB Serial', + ); + lastError = error; + // Try next candidate + } catch (error, stackTrace) { + _status = UsbSerialStatus.disconnected; + debugPrint( + '[USB Serial] Unexpected error opening $candidate: $error\n$stackTrace', + ); + _debugLogService?.error( + 'Unexpected error opening $candidate: $error', + tag: 'USB Serial', + ); + rethrow; + } + } + + if (!opened) { _status = UsbSerialStatus.disconnected; - throw StateError( - 'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})', - ); - } catch (error) { - _serial?.free(); - _serial = null; - _status = UsbSerialStatus.disconnected; - rethrow; + final primary = candidates.first; + final msg = lastError != null + ? 'Failed to open USB port $primary: ${lastError.msg} (code ${lastError.error})' + : 'Failed to open USB port $primary'; + debugPrint('[USB Serial] $msg'); + _debugLogService?.error(msg, tag: 'USB Serial'); + throw StateError(msg); } } @@ -149,7 +261,7 @@ class UsbSerialService { onDone: _handleSerialDone, ); } else { - _dataSubscription = _nativeSerial.onSerialData.stream.listen( + _dataSubscription = _serial!.onSerialData.stream.listen( _handleSerialData, onError: _handleSerialError, onDone: _handleSerialDone, @@ -173,7 +285,7 @@ class UsbSerialService { throw StateError(error.message ?? error.code); } } else { - _nativeSerial.write(packet); + _serial!.write(packet); } } @@ -184,28 +296,40 @@ class UsbSerialService { _connectedPortKey = null; _connectedPortLabel = null; _frameDecoder.reset(); - await _androidDataSubscription?.cancel(); - _androidDataSubscription = null; - await _dataSubscription?.cancel(); - _dataSubscription = null; if (_useAndroidUsbHost) { + await _androidDataSubscription?.cancel(); + _androidDataSubscription = null; try { await _androidMethodChannel.invokeMethod('disconnect'); } catch (_) { // Ignore errors while closing. } } else { + // IMPORTANT: Close and free the native port FIRST, before cancelling the + // Dart subscription. The native SerialThread is blocked on a read(); once + // closePort() is called it unblocks and the thread exits. If we cancel + // the Dart subscription first (freeing the FFI callback pointer) and the + // thread fires one final callback before noticing the port is gone, Dart + // crashes with "Callback invoked after it has been deleted". + final serial = _serial; + _serial = null; + _lastSerial = null; try { - if (_serial?.isOpen() == FlOpenStatus.open) { - _serial?.closePort(); + if (serial?.isOpen() == FlOpenStatus.open) { + serial?.closePort(); } } catch (_) { // Ignore errors while closing. } + try { + serial?.free(); + } catch (_) {} - _serial?.free(); - _serial = null; + // Now it is safe to cancel the Dart subscription — the native thread has + // already seen the port close and will not fire any more callbacks. + await _dataSubscription?.cancel(); + _dataSubscription = null; } _status = UsbSerialStatus.disconnected; } @@ -306,6 +430,28 @@ class UsbSerialService { tag: 'USB Serial', ); } + + /// Returns an ordered list of port paths to try for [portName]. + /// + /// On macOS, USB serial devices appear as both `/dev/cu.*` (call-out, the + /// correct mode for outgoing serial connections) and `/dev/tty.*` (dial-in). + /// `flserial` may list one variant while only the other is actually openable + /// at a given moment. We prefer `cu.*` but automatically include the `tty.*` + /// sibling as a fallback, and vice-versa. + List _buildPortCandidates(String normalizedPort) { + if (!Platform.isMacOS) return [normalizedPort]; + const cuPrefix = '/dev/cu.'; + const ttyPrefix = '/dev/tty.'; + if (normalizedPort.startsWith(cuPrefix)) { + final suffix = normalizedPort.substring(cuPrefix.length); + return [normalizedPort, '$ttyPrefix$suffix']; + } + if (normalizedPort.startsWith(ttyPrefix)) { + final suffix = normalizedPort.substring(ttyPrefix.length); + return [normalizedPort, '$cuPrefix$suffix']; + } + return [normalizedPort]; + } } enum UsbSerialStatus { disconnected, connecting, connected, disconnecting } diff --git a/lib/utils/macos_usb_device_names.dart b/lib/utils/macos_usb_device_names.dart new file mode 100644 index 00000000..ad521f85 --- /dev/null +++ b/lib/utils/macos_usb_device_names.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +/// Queries the macOS IOKit registry via [ioreg] to build a map of serial port +/// callout device paths to human-readable USB device names. +/// +/// The [flserial] native library uses the deprecated [IOUSBDevice] IOKit class +/// to resolve device names, but macOS 10.15+ renamed it to [IOUSBHostDevice]. +/// As a result flserial always returns "n/a" for USB product/vendor info on +/// modern macOS. This utility bypasses that limitation by invoking ioreg +/// directly and parsing its output. +/// +/// Returns a Map of e.g. `"/dev/cu.usbmodem1101"` → `"Nordic NRF52 DK"`. +/// Devices without a USB product name are not included in the map. +Future> queryMacOsUsbDeviceNames() async { + assert(Platform.isMacOS); + try { + final result = await Process.run('ioreg', [ + '-r', + '-c', + 'IOUSBHostDevice', + '-l', + ], stdoutEncoding: const SystemEncoding()); + if (result.exitCode != 0) return const {}; + return _parseIoregOutput(result.stdout as String); + } catch (_) { + return const {}; + } +} + +Map _parseIoregOutput(String output) { + final lines = output.split('\n'); + final result = {}; + + // We accumulate the current device block's properties. + // A new block starts at a line beginning with "+-o " which indicates a + // top-level IOUSBHostDevice entry in the ioreg tree. + String? currentVendor; + String? currentProduct; + final List currentPorts = []; + + void flushBlock() { + if (currentPorts.isNotEmpty && + (currentVendor != null || currentProduct != null)) { + final parts = [ + if (currentVendor != null && currentVendor!.isNotEmpty) currentVendor!, + if (currentProduct != null && currentProduct!.isNotEmpty) + currentProduct!, + ]; + final name = parts.join(' '); + for (final port in currentPorts) { + result[port] = name; + } + } + currentVendor = null; + currentProduct = null; + currentPorts.clear(); + } + + for (final line in lines) { + // A new top-level device block begins here. + if (line.startsWith('+-o ')) { + flushBlock(); + continue; + } + // USB Product Name (appears at multiple depths in the tree, first wins) + final productMatch = _kProductName.firstMatch(line); + if (productMatch != null && currentProduct == null) { + currentProduct = productMatch.group(1)?.trim(); + continue; + } + // USB Vendor Name + final vendorMatch = _kVendorName.firstMatch(line); + if (vendorMatch != null && currentVendor == null) { + currentVendor = vendorMatch.group(1)?.trim(); + continue; + } + // IOCalloutDevice — the /dev/cu.xxx path our app uses + final calloutMatch = _kCalloutDevice.firstMatch(line); + if (calloutMatch != null) { + final port = calloutMatch.group(1)?.trim(); + if (port != null && port.isNotEmpty) { + currentPorts.add(port); + } + } + } + flushBlock(); + return result; +} + +final RegExp _kProductName = RegExp(r'"USB Product Name" = "([^"]*)"'); +final RegExp _kVendorName = RegExp(r'"USB Vendor Name" = "([^"]*)"'); +final RegExp _kCalloutDevice = RegExp(r'"IOCalloutDevice" = "([^"]*)"'); diff --git a/lib/utils/platform_info.dart b/lib/utils/platform_info.dart index e3fd4289..a388932a 100644 --- a/lib/utils/platform_info.dart +++ b/lib/utils/platform_info.dart @@ -35,7 +35,8 @@ class PlatformInfo { static bool get isDesktop => isMacOS || isWindows || isLinux; /// Whether the current platform supports a native USB serial backend. - static bool get supportsNativeUsbSerial => isAndroid || isWindows || isLinux; + static bool get supportsNativeUsbSerial => + isAndroid || isWindows || isLinux || isMacOS; /// Whether the current browser supports the Web Serial backend. static bool get supportsWebSerial => isWeb && isChrome; diff --git a/lib/utils/usb_port_labels.dart b/lib/utils/usb_port_labels.dart index ff32937b..1eb87960 100644 --- a/lib/utils/usb_port_labels.dart +++ b/lib/utils/usb_port_labels.dart @@ -6,16 +6,25 @@ String normalizeUsbPortName(String portLabel) { return normalized.trim(); } +/// Returns a human-readable name for a serial port label. +/// +/// The native flserial library encodes port info as a ` - `-separated string: +/// `" - - "` +/// +/// This function extracts the *description* field (index 1) and discards the +/// raw hardware_id, which is not user-friendly. If the description is missing +/// or unhelpful (e.g. "n/a"), it falls back to the raw port name. String friendlyUsbPortName(String portLabel) { - final separatorIndex = portLabel.indexOf(' - '); - if (separatorIndex < 0) { + final parts = portLabel.split(' - '); + if (parts.length < 2) { return portLabel.trim(); } - final friendlyName = portLabel.substring(separatorIndex + 3).trim(); - if (friendlyName.isEmpty) { - return normalizeUsbPortName(portLabel); + // parts[0] = port name, parts[1] = description, parts[2+] = hardware id + final description = parts[1].trim(); + if (description.isEmpty || description.toLowerCase() == 'n/a') { + return parts[0].trim(); } - return friendlyName; + return description; } String describeWebUsbPort({ diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 8224cfba..58b4d017 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - flserial (0.0.1): + - FlutterMacOS - flutter_blue_plus_darwin (0.0.2): - Flutter - FlutterMacOS @@ -24,6 +26,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - flserial (from `Flutter/ephemeral/.symlinks/plugins/flserial/macos`) - flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) @@ -36,6 +39,8 @@ DEPENDENCIES: - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) EXTERNAL SOURCES: + flserial: + :path: Flutter/ephemeral/.symlinks/plugins/flserial/macos flutter_blue_plus_darwin: :path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin flutter_local_notifications: @@ -58,6 +63,7 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos SPEC CHECKSUMS: + flserial: 3c161e076dfc73458ec5803e7a9a9d2bb85fadf6 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index f31e9afc..17455efd 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -12,6 +12,12 @@ com.apple.security.device.bluetooth + com.apple.security.device.usb + + com.apple.security.temporary-exception.files.absolute-path.read-write + + /dev/ + com.apple.security.device.camera diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 29ef507e..11bd5b8f 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -8,6 +8,12 @@ com.apple.security.device.bluetooth + com.apple.security.device.usb + + com.apple.security.temporary-exception.files.absolute-path.read-write + + /dev/ + com.apple.security.device.camera diff --git a/test/utils/usb_port_labels_test.dart b/test/utils/usb_port_labels_test.dart index e3750059..b2d88bd3 100644 --- a/test/utils/usb_port_labels_test.dart +++ b/test/utils/usb_port_labels_test.dart @@ -11,19 +11,47 @@ void main() { ); }); - test('friendlyUsbPortName prefers suffix when present', () { - expect( - friendlyUsbPortName( - 'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667', - ), - 'USB Serial Device (COM6) - USB\\VID_2886&PID_1667', - ); - }); + test( + 'friendlyUsbPortName returns only description, not hardware_id (3-part label)', + () { + expect( + friendlyUsbPortName( + 'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667', + ), + 'USB Serial Device (COM6)', + ); + }, + ); test( - 'friendlyUsbPortName falls back to normalized port when suffix is empty', + 'friendlyUsbPortName works for macOS-style 3-part label with USB product name', () { - expect(friendlyUsbPortName('COM6 - '), 'COM6'); + expect( + friendlyUsbPortName( + '/dev/cu.usbmodem1101 - Nordic Semiconductor nRF52 DK - USB VID:PID=1915:520f SNR=ABCDEF', + ), + 'Nordic Semiconductor nRF52 DK', + ); + }, + ); + + test( + 'friendlyUsbPortName falls back to port name when description is n/a', + () { + expect( + friendlyUsbPortName('/dev/cu.Bluetooth-Incoming-Port - n/a - n/a'), + '/dev/cu.Bluetooth-Incoming-Port', + ); + }, + ); + + test( + 'friendlyUsbPortName handles 2-part label (no hardware_id) correctly', + () { + expect( + friendlyUsbPortName('COM6 - USB Serial Device (COM6)'), + 'USB Serial Device (COM6)', + ); }, );