Skip to content

feat(hid): native agent-free touch via CoreDevice universalhidservice (iOS 17+) - #835

Open
danielpaulus wants to merge 1 commit into
mainfrom
feat/hid-native-touch
Open

feat(hid): native agent-free touch via CoreDevice universalhidservice (iOS 17+)#835
danielpaulus wants to merge 1 commit into
mainfrom
feat/hid-native-touch

Conversation

@danielpaulus

Copy link
Copy Markdown
Owner

What this is

A new ios hid command family that taps the device by sending raw HID reports over RemoteXPC through the Developer Disk Image's dtuhidd daemon — no WebDriverAgent, no DeviceKit, no XCUITest runner. It ports the touch path of pymobiledevice3's com.apple.coredevice.hid.universalhidservice.

ios hid tap --x=<0-65535> --y=<0-65535> [--raw] [options]

Coordinates are normalized UInt16 (0..65535, 32768 = screen center). A tap is one CONTACT report followed by one RELEASE report to the mainTouchscreen surface (_ServiceID 257).

New package ios/hid

  • Report builder (hid.go): the exact 58-byte mainTouchscreen report (report ID 0x09), unit-tested byte-for-byte plus coordinate/timestamp mapping.
  • Send path: uses the plain {featureIdentifier, messageType, payload} CoreDevice remote-feature envelope (same shape as the pasteboard/deviceinfo services), reusing ios.ConnectToXpcServiceTunnelIface + the ios/xpc encoder. No RemoteXPC reinvented; the frozen dtx/testmanagerd/nskeyedarchiver code is untouched.
  • Media-stream auth gate (mediastream.go + mediaoffer.go): ports displayservice / action.mediastreamstart. Builds the negotiatorOffer protobuf + binary plist (the video mediaBlob is verified byte-for-byte against Apple's captured Xcode template in a unit test), binds a kernel-UDP RTP receiver on the host's tunnel-side IPv6, drains the RTP, and holds the stream open while touches are sent (pymd3's touch_session pattern). --raw skips the gate.

Empirical finding (the important part)

Tested on the only iOS 26 device available: 00008110-001C58C00A88401E, iOS 26.5, on office01, over its persistent go-ios tunnel.

  • The mounted DDI initially pinned by go-ios (ddi-15F31d) is far too old to expose universalhidservice at all — it predates dtuhidd. Mounting the modern personalized DDI (27A5194q, requires a reboot to swap) makes com.apple.coredevice.hid.universalhidservice and com.apple.coredevice.displayservice appear in RSD, and the dtuhidd launchd plist confirms the exact service/feature names this code targets.

  • Milestone 1 — raw taps (no media stream): dropped. Repeated taps across the whole screen produced zero observable change (byte-identical screenshots; the display never woke). Consistent with pymobiledevice3's documented gate.

  • Milestone 2 — media-stream gate: refused by the OS. The startmediastream invoke is well-formed and reaches the daemon, which returns a specific CoreDevice error rather than a parse error:

    com.apple.dt.CoreDeviceError code 9021: "Remote control requires iOS 27.0 or later on this device."

Conclusion: native agent-free HID touch is gated by Apple to iOS 27.0+. On iOS 26.5 the device itself refuses to open the media stream that authenticates the HID surfaces, so touches can't be routed to UIKit. This is an OS version gate, not a defect — the offer is accepted and parsed (specific version error, not "Invalid Parameter"), and the byte-for-byte template test confirms wire correctness. The command is ready to work on iOS 27 devices.

Options considered

  • Raw HID only (no gate): simplest, but taps are silently dropped on 26.5 (and per pymd3, on 17–26 generally). Kept as --raw for A/B testing per iOS build.
  • Media-stream gate (implemented, default): the authoritative path. Uses a kernel-UDP receiver rather than pymd3's userspace pytcp stack because go-ios's tunnel here is a kernel utun (userspaceTun:false), so a normal host socket is reachable by the device.

Backing ios remote input

hid.FractionToNormalized (fraction→UInt16) is exported and tested so a future ios remote can map click fractions straight to taps once it drives this package.

Testing

  • go build ./..., go vet ./..., gofmt -l, go test ./... all clean.
  • Unit tests: exact 58-byte report, coordinate LE encoding, 48-bit timestamp truncation, CONTACT-then-RELEASE ordering, fraction→normalized mapping, and the negotiatorOffer protobuf matching Apple's captured template.
  • On-device: verified --raw sends cleanly and the gated path surfaces the iOS-27 error above.

🤖 Generated with Claude Code

… (iOS 17+)

Add an `ios hid` command family that taps the device by sending raw HID
reports over RemoteXPC through the DDI's dtuhidd daemon — no WDA, no
DeviceKit, no XCUITest runner. Ports the touch path of pymobiledevice3's
com.apple.coredevice.hid.universalhidservice.

New package ios/hid:
- 58-byte mainTouchscreen report builder (report ID 0x09, surface 257),
  unit-tested to the exact byte layout + coordinate/timestamp mapping.
- Tap = CONTACT + RELEASE via the plain {featureIdentifier,messageType,
  payload} envelope (reuses ios.ConnectToXpcServiceTunnelIface + ios/xpc).
- Media-stream auth gate (displayservice / action.mediastreamstart): builds
  the negotiatorOffer protobuf + binary plist (byte-for-byte matched against
  Apple's captured Xcode template in a unit test), opens a kernel-UDP RTP
  receiver on the host tunnel address, drains it, and holds the stream open
  while touches are sent. `--raw` skips the gate.

CLI: `ios hid tap --x=<0-65535> --y=<0-65535> [--raw]`.

Empirical finding (iPhone 00008110..., iOS 26.5, newer DDI 27A5194q mounted):
raw HID taps are dropped (display never wakes) and the media-stream gate the
touch path depends on is refused by the device with CoreDeviceError 9021
"Remote control requires iOS 27.0 or later on this device." So native
agent-free touch is gated to iOS 27+ by Apple; the code is correct and
ready for iOS 27 devices. FractionToNormalized is exported so this can back
`ios remote` input once available.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@shamanec

shamanec commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Actually raw HID events work just fine on iOS 18, 26.5, 27. Not sure if it works on iOS 17 because I have no device to test. I have it implemented in a private repo

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants