diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index f1ddbe6..3bda38f 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Connected iPhones and iPads show up faster, and their device frame loads + without waiting on `devicectl`. - Android emulators stream straight from the emulator on macOS, like Android Studio's embedded emulator: a steady 60 fps with less load on the emulator, sized to the view. Older emulators, ones started without diff --git a/app/lib/src/stream/ios/ios_device_session.dart b/app/lib/src/stream/ios/ios_device_session.dart index 060593a..7b9d4ca 100644 --- a/app/lib/src/stream/ios/ios_device_session.dart +++ b/app/lib/src/stream/ios/ios_device_session.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; +import 'package:simutil_apple/simutil_apple.dart'; import 'package:simutil_core/simutil_core.dart'; import 'package:simutil_app/src/stream/ios/apple_chrome.dart'; @@ -20,7 +21,7 @@ class IosDeviceSession implements DeviceSession { _ensureHandler(); } - /// devicectl identifier. + /// Hardware UDID. final String udid; /// Device name, as the capture device reports it. @@ -91,8 +92,8 @@ class IosDeviceSession implements DeviceSession { }))!; textureId = result['textureId']! as int; setFrameCounter(this, _framesShown); - // Off the start path: devicectl takes a second or more, and sessions - // start one at a time. + // Off the start path: the model lookup can fall back to devicectl, + // which takes a second or more, and sessions start one at a time. if (chrome == null) unawaited(_attachChrome()); // Live once the first frame reports its size. } on PlatformException catch (e) { @@ -115,8 +116,11 @@ class IosDeviceSession implements DeviceSession { return AppleChrome.load(_chromeChannel, udid, model: model); } - /// Hardware model (`iPhone15,2`) from devicectl. + /// Hardware model (`iPhone15,2`) from lockdownd, or devicectl when + /// usbmuxd does not have the device. Future _productType() async { + final values = await lockdownValues(udid); + if (values?['ProductType'] case final String type) return type; final dir = await Directory.systemTemp.createTemp('simutil-device-'); final file = File('${dir.path}/info.json'); try { diff --git a/packages/simutil/CHANGELOG.md b/packages/simutil/CHANGELOG.md index dbcfdeb..6610ad0 100644 --- a/packages/simutil/CHANGELOG.md +++ b/packages/simutil/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `simutil launch --camera-back` / `--camera-front` feed an Android emulator's camera from your Mac's webcam, an image or a video file (`webcam0`, `image:qr.png`, `video:clip.mp4`). +### Changed + +- Connected iPhones and iPads show up faster: SimUtil asks the Mac's device service directly and only falls back to `devicectl` when that does not answer. + ### Fixed - Headless Android emulators (`simutil launch --headless`) use your computer's GPU, so they run much faster. diff --git a/packages/simutil_apple/CHANGELOG.md b/packages/simutil_apple/CHANGELOG.md index 16454b4..7b44364 100644 --- a/packages/simutil_apple/CHANGELOG.md +++ b/packages/simutil_apple/CHANGELOG.md @@ -2,6 +2,15 @@ ## 1.4.0 +- `IOSDeviceService.getPhysicalDevices` asks usbmuxd (`ListDevices`) and + each device's lockdownd (`GetValue`, no pairing session) instead of + running `devicectl`; it falls back to `devicectl list devices` only when + usbmuxd or a device does not answer. Device ids are now hardware UDIDs on + both paths (`hardwareProperties.udid` from `devicectl`), not CoreDevice + identifiers. New optional `listUsbmuxDevices` constructor parameter for + tests. +- `lockdownValues(udid)` reads a connected device's lockdownd values + (`DeviceName`, `ProductType`, `ProductVersion`, ...). - `IosAutomation.launchWithCamera` grants a simulator app camera access and relaunches it with a camera injector dylib inserted (`SIMCTL_CHILD_DYLD_INSERT_LIBRARIES`) and its shared memory name diff --git a/packages/simutil_apple/lib/simutil_apple.dart b/packages/simutil_apple/lib/simutil_apple.dart index 516309c..71b8b8d 100644 --- a/packages/simutil_apple/lib/simutil_apple.dart +++ b/packages/simutil_apple/lib/simutil_apple.dart @@ -1,4 +1,5 @@ -/// Apple simulator and device APIs for SimUtil via simctl and devicectl. +/// Apple simulator and device APIs for SimUtil via simctl, usbmuxd and +/// devicectl. /// /// Lists iPhone/iPad/Watch/TV simulators and connected hardware, plus /// Xcode Derived Data helpers, simulator screen recording, and slimming @@ -18,5 +19,6 @@ export 'src/simulator_app_converter.dart'; export 'src/simulator_recorder.dart'; export 'src/slim/simulator_slimmer.dart'; export 'src/slim/slim_categories.dart'; +export 'src/usbmux.dart' show lockdownValues; export 'src/xcode_cache_service.dart'; export 'src/ios_automation.dart'; diff --git a/packages/simutil_apple/lib/src/apple_device_watch.dart b/packages/simutil_apple/lib/src/apple_device_watch.dart index 85f8cac..cb90ffe 100644 --- a/packages/simutil_apple/lib/src/apple_device_watch.dart +++ b/packages/simutil_apple/lib/src/apple_device_watch.dart @@ -1,8 +1,7 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:simutil_apple/src/usbmux.dart'; import 'package:simutil_core/simutil_core.dart'; /// CoreSimulator's default device set. @@ -38,65 +37,6 @@ Stream watchSimulatorSet(String deviceSet) { .where((e) => isSimulatorSetChange(e.path.substring(dir.path.length))); } -/// usbmuxd's socket on macOS. -const usbmuxdSocket = '/var/run/usbmuxd'; - -/// The `Listen` request that subscribes to usbmuxd attach / detach events -/// (plist protocol: 16-byte little-endian header, then an XML plist). -Uint8List usbmuxListenRequest({int tag = 1}) { - final body = utf8.encode( - '' - '' - '' - 'MessageTypeListen' - 'ClientVersionStringsimutil' - 'ProgNamesimutil' - 'kLibUSBMuxVersion3' - '', - ); - final header = ByteData(16) - ..setUint32(0, 16 + body.length, Endian.little) - ..setUint32(4, 1, Endian.little) // protocol version: plist - ..setUint32(8, 8, Endian.little) // message: plist - ..setUint32(12, tag, Endian.little); - return Uint8List.fromList([...header.buffer.asUint8List(), ...body]); -} - -/// Splits usbmuxd replies into their `MessageType`s (`Result`, `Attached`, -/// `Detached`, `Paired`). -class UsbmuxDecoder { - final _buffer = BytesBuilder(copy: false); - - static final _messageType = RegExp( - r'MessageType\s*([^<]*)', - ); - - /// Feeds [chunk]; returns the message types of the frames it finished. - List add(List chunk) { - _buffer.add(chunk); - var data = _buffer.takeBytes(); - final types = []; - while (data.length >= 16) { - final length = ByteData.sublistView( - data, - 0, - 4, - ).getUint32(0, Endian.little); - if (length < 16) { - data = Uint8List(0); // corrupt: drop and wait for the reconnect - break; - } - if (data.length < length) break; - final plist = utf8.decode(data.sublist(16, length), allowMalformed: true); - types.add(_messageType.firstMatch(plist)?.group(1) ?? ''); - data = data.sublist(length); - } - _buffer.add(data); - return types; - } -} - /// One usbmuxd `Listen` connection: emits on every attach / detach, ends /// when usbmuxd closes the socket. Stream _usbmuxListenOnce(String socketPath) { @@ -105,15 +45,12 @@ Stream _usbmuxListenOnce(String socketPath) { controller = StreamController( onListen: () async { try { - final connected = socket = - await Socket.connect( - InternetAddress(socketPath, type: InternetAddressType.unix), - 0, - ) - ..add(usbmuxListenRequest()); + final connected = socket = await connectUsbmuxd(socketPath) + ..add(usbmuxRequest('Listen')); final decoder = UsbmuxDecoder(); await for (final chunk in connected) { - for (final type in decoder.add(chunk)) { + for (final message in decoder.add(chunk)) { + final type = message['MessageType']; if (type == 'Attached' || type == 'Detached') controller.add(null); } } diff --git a/packages/simutil_apple/lib/src/ios_device_service.dart b/packages/simutil_apple/lib/src/ios_device_service.dart index ec52abf..6c1dd4c 100644 --- a/packages/simutil_apple/lib/src/ios_device_service.dart +++ b/packages/simutil_apple/lib/src/ios_device_service.dart @@ -4,9 +4,11 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:simutil_apple/src/apple_device_watch.dart'; +import 'package:simutil_apple/src/usbmux.dart' as usbmux; import 'package:simutil_core/simutil_core.dart'; -/// Apple simulators (`simctl`) and physical devices (`devicectl`). +/// Apple simulators (`simctl`) and physical devices (usbmuxd + lockdownd, +/// falling back to `devicectl`). /// /// Covers iPhone, iPad, Watch, TV, and other CoreSimulator runtimes. /// No-ops on non-macOS hosts. @@ -14,12 +16,18 @@ class IOSDeviceService implements DeviceService { /// Creates a service that runs `xcrun` via [CommandExec]. /// /// [pathExists] overrides the directory check used to locate the - /// simulator app (for tests). - IOSDeviceService(this._exec, {bool Function(String path)? pathExists}) - : _pathExists = pathExists ?? _directoryExists; + /// simulator app, and [listUsbmuxDevices] the usbmuxd device lookup + /// (both for tests). + IOSDeviceService( + this._exec, { + bool Function(String path)? pathExists, + Future?> Function()? listUsbmuxDevices, + }) : _pathExists = pathExists ?? _directoryExists, + _listUsbmuxDevices = listUsbmuxDevices ?? usbmux.listUsbmuxDevices; final CommandExec _exec; final bool Function(String path) _pathExists; + final Future?> Function() _listUsbmuxDevices; static bool _directoryExists(String path) => Directory(path).existsSync(); @@ -258,10 +266,16 @@ class IOSDeviceService implements DeviceService { ); } + /// Asks usbmuxd and each device's lockdownd first: no process, and no + /// simulators to filter out. `devicectl` (a second or more) only runs when + /// usbmuxd does not answer. @override Future> getPhysicalDevices() async { if (!Platform.isMacOS) return []; + return await _listUsbmuxDevices() ?? await _listDevicectlDevices(); + } + Future> _listDevicectlDevices() async { final tempDirectory = Directory.systemTemp; final outputFile = File('${tempDirectory.path}/ios_devices.json'); @@ -282,7 +296,7 @@ class IOSDeviceService implements DeviceService { return parsePhysicalDevices(json); } catch (e, st) { - log('IOSDeviceService.getPhysicalDevices error: $e\n$st'); + log('IOSDeviceService._listDevicectlDevices error: $e\n$st'); return []; } finally { if (outputFile.existsSync()) { @@ -322,7 +336,11 @@ class IOSDeviceService implements DeviceService { map['deviceProperties'] as Map? ?? {}; final connectionProps = map['connectionProperties'] as Map? ?? {}; - final identifier = map['identifier'] as String? ?? ''; + final hardware = map['hardwareProperties'] as Map?; + // The hardware UDID, as usbmuxd reports it; `identifier` is a + // CoreDevice UUID. + final identifier = + hardware?['udid'] as String? ?? map['identifier'] as String? ?? ''; final name = deviceProps['name'] as String? ?? ''; final osVersion = deviceProps['osVersionNumber'] as String? ?? ''; if (_deviceReality(map) == 'simulated') continue; diff --git a/packages/simutil_apple/lib/src/usbmux.dart b/packages/simutil_apple/lib/src/usbmux.dart new file mode 100644 index 0000000..8000b78 --- /dev/null +++ b/packages/simutil_apple/lib/src/usbmux.dart @@ -0,0 +1,305 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:simutil_core/simutil_core.dart'; +import 'package:xml/xml.dart'; + +/// usbmuxd's socket on macOS. +const usbmuxdSocket = '/var/run/usbmuxd'; + +/// lockdownd's port on the device, reached through usbmuxd `Connect`. +const _lockdownPort = 62078; + +/// A usbmuxd request (plist protocol: 16-byte little-endian header, then an +/// XML plist) of [messageType] with extra [fields]. +Uint8List usbmuxRequest( + String messageType, { + Map fields = const {}, + int tag = 1, +}) { + final body = utf8.encode( + encodePlist({ + 'MessageType': messageType, + 'ClientVersionString': 'simutil', + 'ProgName': 'simutil', + 'kLibUSBMuxVersion': 3, + ...fields, + }), + ); + final header = ByteData(16) + ..setUint32(0, 16 + body.length, Endian.little) + ..setUint32(4, 1, Endian.little) // protocol version: plist + ..setUint32(8, 8, Endian.little) // message: plist + ..setUint32(12, tag, Endian.little); + return Uint8List.fromList([...header.buffer.asUint8List(), ...body]); +} + +/// A lockdownd request: 4-byte big-endian length, then an XML plist. +Uint8List lockdownRequest(Map message) { + final body = utf8.encode(encodePlist(message)); + final header = ByteData(4)..setUint32(0, body.length); + return Uint8List.fromList([...header.buffer.asUint8List(), ...body]); +} + +/// XML plist of a flat [dict] of strings, integers and booleans. +String encodePlist(Map dict) { + final entries = dict.entries.map((e) { + final value = switch (e.value) { + final bool b => b ? '' : '', + final int i => '$i', + final v => '${_escape('$v')}', + }; + return '${_escape(e.key)}$value'; + }); + return '' + '' + '${entries.join()}'; +} + +String _escape(String s) => + s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + +/// The top-level dict of an XML plist, empty when it is anything else. +Map decodePlist(String xml) { + final root = XmlDocument.parse(xml).rootElement; + final value = root.childElements.map(_plistValue).firstOrNull; + return value is Map ? value : const {}; +} + +Object? _plistValue(XmlElement e) => switch (e.localName) { + 'dict' => _plistDict(e.childElements.toList()), + 'array' => e.childElements.map(_plistValue).toList(), + 'string' || 'date' => e.innerText, + 'integer' => int.tryParse(e.innerText.trim()), + 'real' => double.tryParse(e.innerText.trim()), + 'true' => true, + 'false' => false, + 'data' => base64Decode(e.innerText.replaceAll(RegExp(r'\s'), '')), + _ => null, +}; + +Map _plistDict(List children) => { + for (var i = 0; i + 1 < children.length; i += 2) + children[i].innerText: _plistValue(children[i + 1]), +}; + +/// Splits usbmuxd replies into their plist dicts (`Result`, `Attached`, +/// `Detached`, `Paired`, `DeviceList`). +class UsbmuxDecoder { + final _buffer = BytesBuilder(copy: false); + + /// Feeds [chunk]; returns the messages of the frames it finished. + List> add(List chunk) { + _buffer.add(chunk); + var data = _buffer.takeBytes(); + final messages = >[]; + while (data.length >= 16) { + final length = ByteData.sublistView( + data, + 0, + 4, + ).getUint32(0, Endian.little); + if (length < 16) { + data = Uint8List(0); // corrupt: drop and wait for the reconnect + break; + } + if (data.length < length) break; + final plist = utf8.decode(data.sublist(16, length), allowMalformed: true); + try { + messages.add(decodePlist(plist)); + } on XmlException { + messages.add(const {}); + } + data = data.sublist(length); + } + _buffer.add(data); + return messages; + } +} + +/// Opens a connection to usbmuxd at [socketPath]. +Future connectUsbmuxd(String socketPath) => Socket.connect( + InternetAddress(socketPath, type: InternetAddressType.unix), + 0, +); + +/// Physical Apple devices usbmuxd knows (USB, or Wi-Fi when paired for +/// network sync), named and versioned by each device's lockdownd. +/// +/// Null when usbmuxd or a device's lockdownd does not answer within +/// [timeout], so callers can fall back to `devicectl`. +Future?> listUsbmuxDevices({ + String socketPath = usbmuxdSocket, + Duration timeout = const Duration(seconds: 5), +}) async { + try { + final devices = []; + for (final props in await _attachedDevices(socketPath).timeout(timeout)) { + final values = await _lockdownValues( + socketPath, + props['DeviceID']! as int, + ).timeout(timeout); + devices.add(usbmuxDevice(props, values)); + } + return devices; + } catch (e) { + log('listUsbmuxDevices: $e'); + return null; + } +} + +/// lockdownd values (`DeviceName`, `ProductType`, `ProductVersion`, ...) of +/// the device [udid], or null when usbmuxd does not have it. +Future?> lockdownValues( + String udid, { + String socketPath = usbmuxdSocket, + Duration timeout = const Duration(seconds: 5), +}) async { + try { + final serial = _serial(udid); + final props = (await _attachedDevices(socketPath).timeout( + timeout, + )).where((p) => _serial('${p['SerialNumber']}') == serial).firstOrNull; + if (props == null) return null; + return await _lockdownValues( + socketPath, + props['DeviceID']! as int, + ).timeout(timeout); + } catch (e) { + log('lockdownValues: $e'); + return null; + } +} + +/// A [Device] from usbmuxd device [properties] and lockdownd [values]. +/// Pure function (no I/O) so it is unit-testable on any platform. +Device usbmuxDevice( + Map properties, + Map values, +) { + final version = values['ProductVersion'] as String?; + return Device.ios( + id: values['UniqueDeviceID'] as String? ?? '${properties['SerialNumber']}', + name: values['DeviceName'] as String? ?? '', + platform: version == null ? null : 'iOS $version', + type: DeviceType.physical, + state: DeviceState.booted, + ); +} + +/// Serial numbers compared without the dash newer UDIDs carry. +String _serial(String udid) => udid.replaceAll('-', '').toUpperCase(); + +/// usbmuxd `ListDevices` properties, one per device: a device on both USB +/// and Wi-Fi is listed twice, and keeps its USB entry. +Future>> _attachedDevices(String socketPath) async { + final socket = await connectUsbmuxd(socketPath); + try { + final reader = _UsbmuxReader(socket)..send(usbmuxRequest('ListDevices')); + final reply = await reader.next(); + final all = [ + for (final entry in reply['DeviceList'] as List? ?? const []) + if ((entry as Map)['Properties'] case final Map props) + props.cast(), + ]..sort((a, b) => _rank(a) - _rank(b)); + final seen = {}; + return [ + for (final props in all) + if (seen.add(_serial('${props['SerialNumber']}'))) props, + ]; + } finally { + socket.destroy(); + } +} + +int _rank(Map props) => + props['ConnectionType'] == 'USB' ? 0 : 1; + +/// lockdownd's `GetValue` (no session, so no pairing record needed) on the +/// usbmuxd device [deviceId]. +Future> _lockdownValues( + String socketPath, + int deviceId, +) async { + final socket = await connectUsbmuxd(socketPath); + try { + final reader = _UsbmuxReader(socket) + ..send( + usbmuxRequest( + 'Connect', + fields: { + 'DeviceID': deviceId, + // usbmuxd wants the port in network byte order. + 'PortNumber': ((_lockdownPort & 0xff) << 8) | (_lockdownPort >> 8), + }, + ), + ); + final result = await reader.next(); + if (result['Number'] != 0) { + throw StateError('usbmuxd Connect failed: ${result['Number']}'); + } + // The socket is now a raw tunnel to lockdownd. + reader.send(lockdownRequest({'Label': 'simutil', 'Request': 'GetValue'})); + final length = ByteData.sublistView(await reader.read(4)).getUint32(0); + final reply = decodePlist(utf8.decode(await reader.read(length))); + if (reply['Value'] case final Map values) { + return values.cast(); + } + throw StateError('lockdownd GetValue failed: ${reply['Error']}'); + } finally { + socket.destroy(); + } +} + +/// Exact-length reads over a usbmuxd socket. +class _UsbmuxReader { + _UsbmuxReader(this._socket) { + _socket.listen( + (chunk) { + _bytes = Uint8List.fromList([..._bytes, ...chunk]); + _wake(); + }, + onError: (Object _) => _closed(), + onDone: _closed, + cancelOnError: true, + ); + } + + final Socket _socket; + var _bytes = Uint8List(0); + var _done = false; + Completer? _more; + + void send(List bytes) => _socket.add(bytes); + + /// The next usbmuxd reply. + Future> next() async { + final header = await read(16); + final length = ByteData.sublistView(header).getUint32(0, Endian.little); + return decodePlist(utf8.decode(await read(length - 16))); + } + + Future read(int length) async { + while (_bytes.length < length) { + if (_done) throw const SocketException('usbmuxd closed the connection'); + await (_more = Completer()).future; + } + final out = Uint8List.fromList(_bytes.sublist(0, length)); + _bytes = Uint8List.fromList(_bytes.sublist(length)); + return out; + } + + void _closed() { + _done = true; + _wake(); + } + + void _wake() { + _more?.complete(); + _more = null; + } +} diff --git a/packages/simutil_apple/pubspec.yaml b/packages/simutil_apple/pubspec.yaml index 12703d0..4809f04 100644 --- a/packages/simutil_apple/pubspec.yaml +++ b/packages/simutil_apple/pubspec.yaml @@ -1,5 +1,5 @@ name: simutil_apple -description: Apple simulator and device APIs for SimUtil via simctl and devicectl. +description: Apple simulator and device APIs for SimUtil via simctl, usbmuxd and devicectl. version: 1.4.0 homepage: https://github.com/dungngminh/simutil repository: https://github.com/dungngminh/simutil/tree/main/packages/simutil_apple @@ -13,6 +13,7 @@ resolution: workspace dependencies: path: ^1.9.0 simutil_core: ^1.3.0 + xml: ^7.1.0 dev_dependencies: lints: ^6.0.0 diff --git a/packages/simutil_apple/test/apple_device_watch_test.dart b/packages/simutil_apple/test/apple_device_watch_test.dart index 89de366..a3d4dad 100644 --- a/packages/simutil_apple/test/apple_device_watch_test.dart +++ b/packages/simutil_apple/test/apple_device_watch_test.dart @@ -1,6 +1,3 @@ -import 'dart:convert'; -import 'dart:typed_data'; - import 'package:simutil_apple/src/apple_device_watch.dart'; import 'package:test/test.dart'; @@ -15,31 +12,4 @@ void main() { expect(isSimulatorSetChange('/$udid/data/Library/x.db'), isFalse); expect(isSimulatorSetChange('/.DS_Store'), isFalse); }); - - Uint8List frame(String type) { - final body = utf8.encode( - 'MessageType$type' - '', - ); - final header = ByteData(16) - ..setUint32(0, 16 + body.length, Endian.little) - ..setUint32(4, 1, Endian.little) - ..setUint32(8, 8, Endian.little); - return Uint8List.fromList([...header.buffer.asUint8List(), ...body]); - } - - test('usbmux decoder splits frames across chunks', () { - final decoder = UsbmuxDecoder(); - final bytes = [...frame('Result'), ...frame('Attached')]; - expect(decoder.add(bytes.sublist(0, 20)), isEmpty); - expect(decoder.add(bytes.sublist(20)), ['Result', 'Attached']); - expect(decoder.add(frame('Detached')), ['Detached']); - }); - - test('Listen request header carries its length', () { - final request = usbmuxListenRequest(); - final length = ByteData.sublistView(request).getUint32(0, Endian.little); - expect(length, request.length); - expect(utf8.decode(request.sublist(16)), contains('Listen')); - }); } diff --git a/packages/simutil_apple/test/services/ios_device_service_test.dart b/packages/simutil_apple/test/services/ios_device_service_test.dart index 2cc2411..f757eff 100644 --- a/packages/simutil_apple/test/services/ios_device_service_test.dart +++ b/packages/simutil_apple/test/services/ios_device_service_test.dart @@ -158,6 +158,65 @@ void main() { test('returns empty list for missing result', () { expect(IOSDeviceService.parsePhysicalDevices({}), isEmpty); }); + + test('uses the hardware UDID over the CoreDevice identifier', () { + final json = { + 'result': { + 'devices': [ + { + 'identifier': 'BB41C231-E589-59F1-B499-80349FE48FA5', + 'deviceProperties': {'name': 'iPhone', 'osVersionNumber': '26'}, + 'hardwareProperties': { + 'reality': 'physical', + 'udid': '00008130-000A2DDA1AE0001C', + }, + 'connectionProperties': {'transportType': 'wired'}, + }, + ], + }, + }; + + expect( + IOSDeviceService.parsePhysicalDevices(json).single.id, + '00008130-000A2DDA1AE0001C', + ); + }); + }); + + group('getPhysicalDevices', () { + final usbmuxPhone = Device.ios( + id: 'U1', + name: 'iPhone', + state: DeviceState.booted, + type: DeviceType.physical, + ); + + test('uses usbmuxd without running devicectl', () async { + if (!Platform.isMacOS) return; + final exec = FakeCommandExec((_, _) { + throw StateError('devicectl should not run'); + }); + final service = IOSDeviceService( + exec, + listUsbmuxDevices: () async => [usbmuxPhone], + ); + + expect(await service.getPhysicalDevices(), [usbmuxPhone]); + expect(exec.calls, isEmpty); + }); + + test('falls back to devicectl when usbmuxd does not answer', () async { + if (!Platform.isMacOS) return; + final exec = FakeCommandExec((_, _) => null); + final service = IOSDeviceService( + exec, + pathExists: (_) => false, + listUsbmuxDevices: () async => null, + ); + + expect(await service.getPhysicalDevices(), isEmpty); + expect(exec.calls.map((c) => c.arguments.first), contains('devicectl')); + }); }); group('resolveSimulatorAppPath', () { @@ -327,7 +386,7 @@ void main() { final exec = FakeCommandExec((_, _) { throw StateError('command should not run on non-macOS'); }); - final service = IOSDeviceService(exec); + final service = IOSDeviceService(exec, listUsbmuxDevices: () async => []); if (Platform.isMacOS) { // On macOS the guard does not apply; nothing to assert here. diff --git a/packages/simutil_apple/test/usbmux_test.dart b/packages/simutil_apple/test/usbmux_test.dart new file mode 100644 index 0000000..320b778 --- /dev/null +++ b/packages/simutil_apple/test/usbmux_test.dart @@ -0,0 +1,191 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:simutil_apple/src/usbmux.dart'; +import 'package:simutil_core/simutil_core.dart'; +import 'package:test/test.dart'; + +void main() { + Uint8List frame(String type) { + final body = utf8.encode( + 'MessageType$type' + '', + ); + final header = ByteData(16) + ..setUint32(0, 16 + body.length, Endian.little) + ..setUint32(4, 1, Endian.little) + ..setUint32(8, 8, Endian.little); + return Uint8List.fromList([...header.buffer.asUint8List(), ...body]); + } + + test('decoder splits frames across chunks', () { + final decoder = UsbmuxDecoder(); + final bytes = [...frame('Result'), ...frame('Attached')]; + expect(decoder.add(bytes.sublist(0, 20)), isEmpty); + expect(decoder.add(bytes.sublist(20)).map((m) => m['MessageType']), [ + 'Result', + 'Attached', + ]); + expect(decoder.add(frame('Detached')).single['MessageType'], 'Detached'); + }); + + test('request header carries its length', () { + final request = usbmuxRequest('Listen'); + final length = ByteData.sublistView(request).getUint32(0, Endian.little); + expect(length, request.length); + expect(utf8.decode(request.sublist(16)), contains('Listen')); + }); + + test('plist round trip escapes text', () { + final plist = encodePlist({'Name': 'A & ', 'Port': 62078, 'On': true}); + expect(decodePlist(plist), {'Name': 'A & ', 'Port': 62078, 'On': true}); + }); + + test('decodes nested plists', () { + final plist = decodePlist(''' + + DeviceList + DeviceID7 + PropertiesSerialNumberX + + DataAQI= + Off +'''); + expect(plist['DeviceList'], [ + { + 'DeviceID': 7, + 'Properties': {'SerialNumber': 'X'}, + }, + ]); + expect(plist['Data'], [1, 2]); + expect(plist['Off'], isFalse); + }); + + test('device from lockdown values, falling back to the serial', () { + final device = usbmuxDevice( + {'SerialNumber': '00008120001250920280201E'}, + { + 'DeviceName': 'My iPhone', + 'ProductVersion': '26.1', + 'UniqueDeviceID': '00008120-001250920280201E', + }, + ); + expect(device.id, '00008120-001250920280201E'); + expect(device.name, 'My iPhone'); + expect(device.platform, 'iOS 26.1'); + expect(device.type, DeviceType.physical); + expect(device.state, DeviceState.booted); + expect(usbmuxDevice({'SerialNumber': 'S'}, {}).id, 'S'); + }); + + group('against a fake usbmuxd', () { + late Directory dir; + late ServerSocket server; + late String socketPath; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('usbmux'); + socketPath = '${dir.path}/usbmuxd'; + server = + await ServerSocket.bind( + InternetAddress(socketPath, type: InternetAddressType.unix), + 0, + ) + ..listen(_fakeUsbmuxd); + }); + + tearDown(() async { + await server.close(); + await dir.delete(recursive: true); + }); + + test('lists each device once, named by lockdownd', () async { + final devices = await listUsbmuxDevices(socketPath: socketPath); + expect(devices, hasLength(1)); + expect(devices!.single.id, '00008120-001250920280201E'); + expect(devices.single.name, 'Dũng’s iPhone'); + expect(devices.single.platform, 'iOS 26.1'); + }); + + test('reads lockdown values by UDID', () async { + final values = await lockdownValues( + '00008120-001250920280201E', + socketPath: socketPath, + ); + expect(values?['ProductType'], 'iPhone15,2'); + expect(await lockdownValues('other', socketPath: socketPath), isNull); + }); + }); + + test('null when usbmuxd is unreachable', () async { + expect(await listUsbmuxDevices(socketPath: '/nonexistent/usbmuxd'), isNull); + }); +} + +/// Answers `ListDevices` with one device on USB and Wi-Fi, and `Connect` +/// to its lockdownd with the device's values. +void _fakeUsbmuxd(Socket client) { + final decoder = UsbmuxDecoder(); + var tunnel = false; + client.listen((chunk) { + if (tunnel) { + // lockdownd GetValue: answer whatever the request. + client.add( + _lockdownReply({ + 'DeviceName': 'Dũng’s iPhone', + 'ProductType': 'iPhone15,2', + 'ProductVersion': '26.1', + 'UniqueDeviceID': '00008120-001250920280201E', + }), + ); + return; + } + for (final message in decoder.add(chunk)) { + switch (message['MessageType']) { + case 'ListDevices': + client.add(_usbmuxReply(_deviceList)); + case 'Connect': + // Lockdownd's port in network order, on the USB entry. + final ok = + message['PortNumber'] == 0x7EF2 && message['DeviceID'] == 3; + client.add(_usbmuxReply(_result(ok ? 0 : 3))); + tunnel = ok; + } + } + }); +} + +String _device(int id, String type) => + 'DeviceID$id' + 'Properties' + 'ConnectionType$type' + 'DeviceID$id' + 'SerialNumber00008120001250920280201E' + ''; + +final _deviceList = + 'DeviceList' + '${_device(9, 'Network')}${_device(3, 'USB')}' + ''; + +String _result(int number) => + 'MessageTypeResult' + 'Number$number'; + +Uint8List _usbmuxReply(String plist) { + final body = utf8.encode(plist); + final header = ByteData(16)..setUint32(0, 16 + body.length, Endian.little); + return Uint8List.fromList([...header.buffer.asUint8List(), ...body]); +} + +Uint8List _lockdownReply(Map values) { + final entries = values.entries + .map((e) => '${e.key}${e.value}') + .join(); + final body = utf8.encode( + 'Value$entries', + ); + final header = ByteData(4)..setUint32(0, body.length); + return Uint8List.fromList([...header.buffer.asUint8List(), ...body]); +}