Skip to content

feat: list iPhones and iPads over usbmuxd + lockdown - #78

Merged
dungngminh merged 1 commit into
mainfrom
feat/usbmux-device-list
Oct 22, 2026
Merged

dungngminh merged 1 commit into
mainfrom
feat/usbmux-device-list

Conversation

@dungngminh

Copy link
Copy Markdown
Owner

Description

Physical Apple devices are listed without spawning devicectl (a second or more per refresh):

  • New simutil_apple/lib/src/usbmux.dart: asks usbmuxd (ListDevices), then each device's lockdownd over a usbmuxd Connect to port 62078 (GetValue, no pairing session) for DeviceName, ProductVersion, ProductType, UniqueDeviceID. Pure Dart sockets + XML plist (xml package, already in the lock file). A device on both USB and Wi-Fi is listed once, preferring USB. 5 s timeout per step.
  • IOSDeviceService.getPhysicalDevices: usbmuxd first, falling back to devicectl list devices only when usbmuxd or a device does not answer (null). The usbmux list only has hardware, so there are no simulators to filter out.
  • Device ids are hardware UDIDs on both paths: the devicectl parser now prefers hardwareProperties.udid over the CoreDevice identifier. devicectl --device accepts UDIDs, so install / launch are unaffected.
  • The usbmuxd framing moves out of apple_device_watch.dart and is shared with the attach/detach watch.
  • Desktop app: IosDeviceSession reads the model for the device frame from lockdown (lockdownValues), with devicectl as the fallback.

Known gap: a device reachable only over a CoreDevice Wi-Fi tunnel (not through usbmuxd) is not listed while usbmuxd answers.

Verified

  • dart analyze --fatal-infos (simutil_apple, simutil, simutil_shared) and flutter analyze (app) clean; simutil_apple tests pass, including end-to-end tests against a fake usbmuxd on a unix socket.
  • Real usbmuxd with nothing plugged in: [] in ~110 ms.
  • Desktop app on macOS with a USB iPhone: listed by name and streamed live (1180×2556).

Type of Change

  • 🔥 New feature (non-breaking change which adds functionality)
  • ✨ Enhancement (non-breaking change which improves existing functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • 🧹 Code refactor
  • 📝 Documentation
  • 🗑️ Chore
  • 📦 Build configuration change
  • ✅ Test

getPhysicalDevices asks usbmuxd (ListDevices) and each device's
lockdownd (GetValue, no pairing session) instead of spawning
`devicectl list devices`, which takes a second or more. devicectl stays
as the fallback when usbmuxd or a device does not answer.

Device ids are hardware UDIDs on both paths (devicectl's
hardwareProperties.udid instead of the CoreDevice identifier), so they
do not change between the two. devicectl --device accepts UDIDs, so
install / launch are unaffected.

The usbmuxd framing moves from apple_device_watch.dart into usbmux.dart
and is shared with the watch. The desktop app reads the device model
for its frame from lockdown too, keeping devicectl as the fallback.
@dungngminh dungngminh added enhancement Improve to better macos labels Oct 22, 2026
@coderabbitai

coderabbitai Bot commented Oct 22, 2026

Copy link
Copy Markdown

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 92e52b96-f134-4c11-ac24-5e27a37c99ec
📥 Commits

Reviewing files that changed from the base of the PR and between d9eeedf and 65c99c3.

📒 Files selected for processing: 12
  • app/CHANGELOG.md
  • app/lib/src/stream/ios/ios_device_session.dart
  • packages/simutil/CHANGELOG.md
  • packages/simutil_apple/CHANGELOG.md
  • packages/simutil_apple/lib/simutil_apple.dart
  • packages/simutil_apple/lib/src/apple_device_watch.dart
  • packages/simutil_apple/lib/src/ios_device_service.dart
  • packages/simutil_apple/lib/src/usbmux.dart
  • packages/simutil_apple/pubspec.yaml
  • packages/simutil_apple/test/apple_device_watch_test.dart
  • packages/simutil_apple/test/services/ios_device_service_test.dart
  • packages/simutil_apple/test/usbmux_test.dart
+-----------------------------------------------+
| Optimization hinders evolution. - Alan Perlis |
+-----------------------------------------------+
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dungngminh
dungngminh merged commit 40f6d3e into main Oct 22, 2026
6 of 7 checks passed
@dungngminh
dungngminh deleted the feat/usbmux-device-list branch October 22, 2026 09:43
try {
final devices = <Device>[];
for (final props in await _attachedDevices(socketPath).timeout(timeout)) {
final values = await _lockdownValues(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: One unresponsive device discards every device listed

_lockdownValues throws for a device that does not answer (a timeout, or a Connect that usbmuxd refuses), and the catch at line 149 turns that into null for the whole list: the devices already read are dropped and the caller falls back to the slow devicectl path. The same happens for the props['DeviceID']! null-check on the next line. Catch per device and skip the ones that fail, so one bad device does not cost the whole listing.

final values = await _lockdownValues(
socketPath,
props['DeviceID']! as int,
).timeout(timeout);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: The timeout never cancels the pending socket work

Future.timeout does not cancel the future it wraps: when it fires, _lockdownValues (and _attachedDevices on line 141) is still blocked in reader.next(), so the finally that calls socket.destroy() never runs and the unix socket is leaked for the life of the process - exactly in the case the timeout exists for (usbmuxd accepts the connection but never answers). The timeout is also applied per step, so one listing can take timeout * (N + 1) (5 s per device) and outlast the caller own budget: the app gives up after 20 s and then shows no iOS devices at all, without ever reaching the devicectl fallback. Destroy the socket when the timeout fires, and give the whole usbmuxd phase a single deadline.

for (final props in await _attachedDevices(socketPath).timeout(timeout)) {
final values = await _lockdownValues(
socketPath,
props['DeviceID']! as int,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: A missing DeviceID throws and loses the whole list

props['DeviceID']! as int is a null-check plus a cast on data that comes from the daemon. The authoritative DeviceID is the one on the DeviceList entry, but _attachedDevices throws that away and keeps only Properties. If the entry has no integer DeviceID, this throws and (through the catch at line 149) the entire result becomes null, so every device is re-listed through the slow devicectl path. Read the entry-level DeviceID in _attachedDevices and skip entries without one.

Future<Map<String, Object?>> next() async {
final header = await read(16);
final length = ByteData.sublistView(header).getUint32(0, Endian.little);
return decodePlist(utf8.decode(await read(length - 16)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The frame length is not validated before the body is read

read(length - 16) assumes length >= 16; UsbmuxDecoder.add checks that on line 106, but next() does not, so a corrupt frame makes read() slice with a negative length (RangeError) and an oversized length makes it wait for bytes that never arrive. The same applies to the lockdownd length prefix on line 247. Validate the length before reading the body.

) {
final version = values['ProductVersion'] as String?;
return Device.ios(
id: values['UniqueDeviceID'] as String? ?? '${properties['SerialNumber']}',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The id fallback does not match the id the other path produces

UniqueDeviceID is normally present, but when it is not the id falls back to usbmuxd SerialNumber, which is the UDID without dashes (and the literal string null when SerialNumber is missing as well). The devicectl path (parsePhysicalDevices) produces the dashed hardwareProperties.udid, so the same phone can get two different ids depending on which path listed it, and the app keys its capture session by that id. Normalize the fallback to the dashed UDID.

'real' => double.tryParse(e.innerText.trim()),
'true' => true,
'false' => false,
'data' => base64Decode(e.innerText.replaceAll(RegExp(r'\s'), '')),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: A malformed data value throws out of the decoder

base64Decode throws a FormatException on invalid base64, but UsbmuxDecoder.add only catches XmlException (line 114), so the exception escapes into the watch loop (apple_device_watch.dart:52) and tears the attach/detach connection down, or aborts a listing in _UsbmuxReader.next. Decode data defensively so one bad frame is skipped instead of killing the stream.

);

test('uses usbmuxd without running devicectl', () async {
if (!Platform.isMacOS) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: The new getPhysicalDevices tests never assert anything in CI

Both new tests return early unless Platform.isMacOS, and CI runs the workspace tests on ubuntu-latest (the macOS job in .github/workflows/ci.yaml only builds the binary), so the behaviour this PR adds - usbmuxd first, devicectl only as a fallback - is never verified. IOSDeviceService already takes pathExists and listUsbmuxDevices overrides for tests; take an isMacOS flag the same way so the guard can be bypassed.

/// Hardware model (`iPhone15,2`) from lockdownd, or devicectl when
/// usbmuxd does not have the device.
Future<String?> _productType() async {
final values = await lockdownValues(udid);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The model lookup opens a second usbmuxd session

lockdownValues(udid) runs a fresh ListDevices + Connect + GetValue (5 s timeout each) on every stream start, although IOSDeviceService.getPhysicalDevices already read the same lockdownd values and dropped ProductType from the Device. When the id is not a UDID (the devicectl path can produce a CoreDevice UUID, which lockdownValues never matches) the lookup is a wasted round trip, and in the fallback case it delays the device frame by up to 10 s before devicectl even starts. Carry the model on Device or reuse the values from the listing.

@kilo-code-bot

kilo-code-bot Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 8 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 4
SUGGESTION 4
Issue Details (click to expand)

WARNING

File Line Issue
packages/simutil_apple/lib/src/usbmux.dart 142 One unresponsive device discards every device already listed (the per-device throw escapes to the catch that returns null for the whole list)
packages/simutil_apple/lib/src/usbmux.dart 145 The per-step timeout never cancels the pending socket work, leaking the socket, and timeout * (N + 1) can outlast the app's 20 s load budget so no devices are shown and devicectl is never reached
packages/simutil_apple/lib/src/usbmux.dart 144 props['DeviceID']! as int throws on daemon data and loses the whole listing; the authoritative DeviceID is on the DeviceList entry
packages/simutil_apple/test/services/ios_device_service_test.dart 195 Both new getPhysicalDevices tests return early off macOS, and CI only runs the tests on ubuntu, so the new fallback logic is never asserted

SUGGESTION

File Line Issue
packages/simutil_apple/lib/src/usbmux.dart 283 The usbmuxd frame length is not validated before the body is read (negative slice / unbounded wait)
packages/simutil_apple/lib/src/usbmux.dart 186 The id fallback yields the dash-less UDID or the literal null, unlike the devicectl path's dashed UDID
packages/simutil_apple/lib/src/usbmux.dart 81 base64Decode throws a FormatException that escapes the decoder's on XmlException catch
app/lib/src/stream/ios/ios_device_session.dart 122 The model lookup opens a second usbmuxd session per stream start instead of reusing the values from the listing
Files Reviewed (12 files)
  • packages/simutil_apple/lib/src/usbmux.dart - 6 issues
  • packages/simutil_apple/test/services/ios_device_service_test.dart - 1 issue
  • app/lib/src/stream/ios/ios_device_session.dart - 1 issue
  • app/CHANGELOG.md - 0 issues
  • packages/simutil/CHANGELOG.md - 0 issues
  • packages/simutil_apple/CHANGELOG.md - 0 issues
  • packages/simutil_apple/lib/simutil_apple.dart - 0 issues
  • packages/simutil_apple/lib/src/apple_device_watch.dart - 0 issues
  • packages/simutil_apple/lib/src/ios_device_service.dart - 0 issues
  • packages/simutil_apple/pubspec.yaml - 0 issues
  • packages/simutil_apple/test/apple_device_watch_test.dart - 0 issues
  • packages/simutil_apple/test/usbmux_test.dart - 0 issues

Fix these issues in Kilo Cloud


Reviewed by free · Input: 153.5K · Output: 34.8K · Cached: 1.2M

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improve to better macos

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants